diff --git a/docs/antd-copy-message-guide.md b/docs/antd-copy-message-guide.md new file mode 100644 index 0000000..2b90600 --- /dev/null +++ b/docs/antd-copy-message-guide.md @@ -0,0 +1,372 @@ +# Ant Design 复制和 Message 功能使用指南 + +> **作者:阿瑞** +> **版本:1.0.0** +> **适用范围:Ant Design 5.x + Next.js 15 + React 19** + +## 📖 概述 + +本文档详细介绍如何在 Ant Design 5.x 中正确使用复制功能和 message 组件,避免常见的警告和错误,提供最佳实践方案。 + +## 🚨 常见警告及解决方案 + +### 1. Message 静态方法警告 + +**警告信息:** +``` +Warning: [antd: message] Static function can not consume context like dynamic theme. Please use 'App' component instead. +``` + +**产生原因:** +- Ant Design 5.x 中的静态方法(如 `message.success()`)无法获取动态主题上下文 +- 静态方法不在 React 组件树中,无法访问 `ConfigProvider` 和 `App` 组件提供的上下文 + +## 💡 正确使用方法 + +### 1. App 组件配置 + +首先确保在 `_app.tsx` 中正确配置 `App` 组件: + +```tsx +// src/pages/_app.tsx +import { ConfigProvider, App } from "antd"; +import zhCN from "antd/locale/zh_CN"; + +function AppConfigProvider({ children }: { children: React.ReactNode }) { + return ( + + +
+ {children} +
+
+
+ ); +} + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ); +} +``` + +### 2. 在组件中使用 useApp Hook + +**正确方式:** + +```tsx +import React from 'react'; +import { App, Button } from 'antd'; + +const { useApp } = App; + +const MyComponent = () => { + // ✅ 使用 useApp hook 获取 message 实例 + const { message } = useApp(); + + const handleClick = () => { + message.success('操作成功!'); + message.error('操作失败!'); + message.warning('警告信息!'); + message.info('提示信息!'); + }; + + return ( + + ); +}; + +export default MyComponent; +``` + +**错误方式:** + +```tsx +import { message } from 'antd'; // ❌ 不推荐:静态导入 + +const MyComponent = () => { + const handleClick = () => { + message.success('操作成功!'); // ❌ 会产生警告 + }; + + return ; +}; +``` + +## 📋 复制功能使用指南 + +### 1. 简单文本复制 + +使用 `Typography.Paragraph` 的 `copyable` 属性: + +```tsx +import { Typography } from 'antd'; +import { App } from 'antd'; + +const { Paragraph } = Typography; +const { useApp } = App; + +const SimpleTextCopy = () => { + const { message } = useApp(); + + const textToCopy = "这是要复制的文本内容"; + + return ( + message.success('复制成功!'), + tooltips: ['点击复制', '复制成功'] + }} + > + {textToCopy} + + ); +}; +``` + +### 2. 自定义复制按钮 + +```tsx +import { Typography, Button } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; + +const CustomCopyButton = () => { + const { message } = useApp(); + const copyText = "自定义复制内容"; + + return ( + , // 自定义图标 + onCopy: () => message.success('内容已复制到剪贴板'), + tooltips: ['复制内容', '复制成功'] + }} + style={{ margin: 0 }} + > + {/* 空内容,只显示复制按钮 */} + + ); +}; +``` + +### 3. 复杂复制逻辑(图片 + 文本) + +对于需要复制图片和文本的复杂场景: + +```tsx +import { Button, Tooltip } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; + +const ComplexCopyFunction = () => { + const { message } = useApp(); + + const handleComplexCopy = async () => { + try { + const textContent = "文本内容"; + + // 尝试复制图片 + 文本 + if (hasImage) { + try { + const imageBlob = await fetchImageAsBlob(); + + const clipboardItems = { + "text/plain": new Blob([textContent], { type: "text/plain" }), + [imageBlob.type]: imageBlob + }; + + const clipboardItem = new ClipboardItem(clipboardItems); + await navigator.clipboard.write([clipboardItem]); + message.success('文本和图片已复制'); + } catch (imageError) { + // 降级到仅文本复制 + await navigator.clipboard.writeText(textContent); + message.success('文本已复制(图片复制失败)'); + } + } else { + // 仅文本复制 + await navigator.clipboard.writeText(textContent); + message.success('文本已复制'); + } + } catch (error) { + console.error('复制失败:', error); + message.error('复制失败'); + } + }; + + return ( + + + + ); +}; +``` + +## 🎯 最佳实践 + +### 1. Message 使用建议 + +```tsx +const BestPracticeExample = () => { + const { message } = useApp(); + + // ✅ 推荐:使用 useApp hook + const showSuccess = () => { + message.success({ + content: '操作成功!', + duration: 3, // 显示时长 + key: 'unique-key', // 唯一键,避免重复显示 + }); + }; + + // ✅ 推荐:加载状态处理 + const showLoading = () => { + message.loading({ + content: '正在处理...', + key: 'loading-key', + duration: 0 // 0 表示不自动关闭 + }); + + // 模拟异步操作 + setTimeout(() => { + message.success({ + content: '处理完成!', + key: 'loading-key' // 同样的 key 会替换之前的 message + }); + }, 2000); + }; + + return ( +
+ + +
+ ); +}; +``` + +### 2. 复制功能建议 + +```tsx +const CopyBestPractice = () => { + const { message } = useApp(); + + // ✅ 推荐:简单文本使用 Paragraph copyable + const simpleCopy = ( + message.success('复制成功'), + }} + > + 简单文本复制 + + ); + + // ✅ 推荐:复杂逻辑使用自定义函数 + Tooltip + const complexCopy = ( + + + + ); + + return ( +
+ {simpleCopy} + {complexCopy} +
+ ); +}; +``` + +## ⚠️ 常见错误 + +### 1. 避免事件冲突 + +```tsx +// ❌ 错误:在 Paragraph copyable 中嵌套按钮会导致事件冲突 + + {/* 会导致点击事件冲突 */} + + +// ✅ 正确:分别处理 +
+ 内容 + +
+``` + +### 2. 避免样式冲突 + +```tsx +// ❌ 错误:fontSize: 0 可能影响可点击区域 + + 内容 + + +// ✅ 正确:使用合适的样式 + + 内容 + +``` + +## 🔧 调试技巧 + +### 1. 检查 App 组件配置 + +确保组件在 `App` 组件内部: + +```tsx +// 在浏览器控制台检查 +console.log('App context:', React.useContext(AppContext)); +``` + +### 2. 验证复制功能 + +```tsx +const testCopy = async () => { + try { + await navigator.clipboard.writeText('测试'); + console.log('复制功能正常'); + } catch (error) { + console.error('复制功能异常:', error); + } +}; +``` + +## 📚 参考资料 + +- [Ant Design App 组件文档](https://ant.design/components/app-cn) +- [Ant Design Typography 组件文档](https://ant.design/components/typography-cn) +- [Ant Design Message 组件文档](https://ant.design/components/message-cn) +- [Web Clipboard API](https://developer.mozilla.org/zh-CN/docs/Web/API/Clipboard) + +## 📝 更新日志 + +- **v1.0.0** (2024-12-XX): 初始版本,包含基础使用方法和最佳实践 + +--- + +> 💡 **提示**:遵循本文档的最佳实践,可以避免常见的警告和错误,提供更好的用户体验。 \ No newline at end of file diff --git a/package.json b/package.json index e97111b..d0578db 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "@ant-design/pro-components": "^2.8.7", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@iconify/react": "^4.1.1", + "@types/lodash": "^4.17.17", "antd": "^5.25.4", "bcryptjs": "^3.0.2", "geist": "^1.4.2", "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", "mongoose": "^8.15.1", "next": "15.3.3", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62c6795..21b34fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@iconify/react': specifier: ^4.1.1 version: 4.1.1(react@19.1.0) + '@types/lodash': + specifier: ^4.17.17 + version: 4.17.17 antd: specifier: ^5.25.4 version: 5.25.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -35,6 +38,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + lodash: + specifier: ^4.17.21 + version: 4.17.21 mongoose: specifier: ^8.15.1 version: 8.15.1 @@ -641,6 +647,9 @@ packages: '@types/jsonwebtoken@9.0.9': resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==} + '@types/lodash@4.17.17': + resolution: {integrity: sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2071,6 +2080,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 20.17.57 + '@types/lodash@4.17.17': {} + '@types/ms@2.1.0': {} '@types/node@20.17.57': diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 58cbfa5..03390cb 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -312,7 +312,18 @@ const Layout: React.FC = ({ children }) => { prefixCls="my-prefix" {...defaultProps} location={{ pathname: router.pathname }} - token={{ header: { colorBgMenuItemSelected: 'rgba(0,0,0,0.04)' } }} + token={{ + // 头部菜单选中项的背景颜色 + header: { colorBgMenuItemSelected: 'rgba(0,0,0,0.04)' }, + // PageContainer 内边距控制 - 完全移除左右空白 + pageContainer: { + // 移除 PageContainer 内容区域的上下内边距 (Block 方向,即垂直方向) + paddingBlockPageContainerContent: 0, + // 移除 PageContainer 内容区域的左右内边距 (Inline 方向,即水平方向) + // 这是消除左右空白的关键配置之一 + paddingInlinePageContainerContent: 0, + } + }} siderMenuType="group" menu={{ request: async () => dynamicRoutes, @@ -351,20 +362,36 @@ const Layout: React.FC = ({ children }) => { locale="zh-CN" > + {/* 最内层容器 - 实际承载页面内容的容器 */}
= ({ id }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await axios.get(`/api/tools/logistics/detail/${id}`); + if (response.data && response.data.length > 0) { + setData(response.data[0]); + } else { + setError('No data found'); + } + } catch (err) { + setError('Error fetching data'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [id]); + + if (loading) { + return ( + +
Loading...
+
+ ); + } + + if (error) { + return ; + } + + if (!data) { + return null; + } + + return ( +
+

物流单号: {data.物流单号}

+

更新时间: {new Date(data.更新时间).toLocaleString()}

+

物流详情:

+
{data.物流详情}
+
+ ); +}; + +export default LogisticsQuery; diff --git a/src/components/logistics/status.tsx b/src/components/logistics/status.tsx new file mode 100644 index 0000000..9df3d4e --- /dev/null +++ b/src/components/logistics/status.tsx @@ -0,0 +1,251 @@ +//src\components\logistics\status.tsx +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { Tag, Spin } from 'antd'; +import MyTooltip from '@/components/tooltip/MyTooltip'; +import { debounce } from 'lodash'; // 导入lodash的debounce函数 + +interface LogisticsStatusProps { + recordId: string; // 关联记录的id + productId: string; // 产品的id +} + +// 添加状态缓存 +const statusCache: Record = {}; +const detailsCache: Record = {}; + +// 缓存过期时间(毫秒) +const CACHE_EXPIRY = 5 * 60 * 1000; // 5分钟 + +const statusColorMapping: { [key: string]: string } = { + '已退回': 'red', + '退回中': 'orange', + '已拒收': 'volcano', + '已签收': 'green', + '派送中': 'blue', + '已发出': 'geekblue', + '已揽收': 'cyan', + '处理中': 'grey', + '待发货': 'magenta' +}; + +// 生成缓存键 +const getCacheKey = (recordId: string, productId: string) => `${recordId}_${productId}`; + +const LogisticsStatus: React.FC = React.memo(({ recordId, productId }) => { + const [logisticsStatus, setLogisticsStatus] = useState(null); + const [logisticsDetails, setLogisticsDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [detailsLoading, setDetailsLoading] = useState(false); + const [logisticsNumber, setLogisticsNumber] = useState(null); + + // 使用 ref 来跟踪当前组件是否已挂载,避免在组件卸载后设置状态 + const isMountedRef = useRef(true); + + // 使用 ref 来存储当前进行中的请求,用于取消 + const currentRequestRef = useRef(null); + + // 检查缓存是否有效 + const isCacheValid = (timestamp: number) => { + return Date.now() - timestamp < CACHE_EXPIRY; + }; + + useEffect(() => { + isMountedRef.current = true; + const cacheKey = getCacheKey(recordId, productId); + const cachedStatus = statusCache[cacheKey]; + + // 如果缓存存在且未过期,使用缓存 + if (cachedStatus && isCacheValid(cachedStatus.timestamp)) { + if (isMountedRef.current) { + setLogisticsStatus(cachedStatus.status); + setLoading(false); + } + return; + } + + const fetchLogisticsStatus = async () => { + if (!isMountedRef.current) return; + + setLoading(true); + + // 取消之前的请求 + if (currentRequestRef.current) { + currentRequestRef.current.abort(); + } + + // 创建新的 AbortController + const abortController = new AbortController(); + currentRequestRef.current = abortController; + + try { + const response = await fetch(`/api/logistics/status?recordId=${recordId}&productId=${productId}`, { + signal: abortController.signal + }); + + if (!response.ok) { + console.error('获取物流状态失败:', `HTTP ${response.status}`); + if (isMountedRef.current) { + setLogisticsStatus(null); + } + return; + } + + const responseData = await response.json(); + const status = responseData.data.物流状态; + + // 更新缓存 + statusCache[cacheKey] = { + status, + timestamp: Date.now() + }; + + if (isMountedRef.current) { + setLogisticsStatus(status); + } + } catch (error: any) { + // 忽略取消的请求错误 + if (error.name !== 'AbortError') { + console.error('获取物流状态失败:', error); + if (isMountedRef.current) { + setLogisticsStatus(null); + } + } + } finally { + if (isMountedRef.current) { + setLoading(false); + } + currentRequestRef.current = null; + } + }; + + fetchLogisticsStatus(); + + // 当依赖项变化时,重置物流详情和物流单号 + if (isMountedRef.current) { + setLogisticsDetails(null); + setLogisticsNumber(null); + } + + // 清理函数 + return () => { + if (currentRequestRef.current) { + currentRequestRef.current.abort(); + currentRequestRef.current = null; + } + }; + }, [recordId, productId]); + + // 组件卸载时的清理 + useEffect(() => { + return () => { + isMountedRef.current = false; + if (currentRequestRef.current) { + currentRequestRef.current.abort(); + } + }; + }, []); + + // 使用debounce防止频繁请求,并添加请求取消机制 + const fetchLogisticsDetails = useCallback( + debounce(async () => { + if (!isMountedRef.current) return; + + const cacheKey = getCacheKey(recordId, productId); + const cachedDetails = detailsCache[cacheKey]; + + // 如果缓存存在且未过期,使用缓存 + if (cachedDetails && isCacheValid(cachedDetails.timestamp)) { + if (isMountedRef.current) { + setLogisticsDetails(cachedDetails.details); + setLogisticsNumber(cachedDetails.number); + } + return; + } + + if (isMountedRef.current) { + setDetailsLoading(true); + } + + try { + const response = await fetch(`/api/logistics/details?recordId=${recordId}&productId=${productId}`); + + if (!response.ok) { + console.error('获取物流详情失败:', `HTTP ${response.status}`); + if (isMountedRef.current) { + setLogisticsDetails('暂无物流详情'); + setLogisticsNumber(''); + } + return; + } + + const responseData = await response.json(); + const details = responseData[0]?.物流详情 || '暂无物流详情'; + const number = responseData[0]?.物流单号 || ''; + + // 更新缓存 + detailsCache[cacheKey] = { + details, + number, + timestamp: Date.now() + }; + + if (isMountedRef.current) { + setLogisticsDetails(details); + setLogisticsNumber(number); + } + } catch (error) { + console.error('获取物流详情失败:', error); + if (isMountedRef.current) { + setLogisticsDetails('暂无物流详情'); + setLogisticsNumber(''); + } + } finally { + if (isMountedRef.current) { + setDetailsLoading(false); + } + } + }, 300), // 300ms的防抖时间 + [recordId, productId] + ); + + if (loading) { + return ; + } + + if (!logisticsStatus) { + return null; // 返回null而不是div,减少不必要的DOM元素 + } + + return ( + + ) : ( +
+ 物流单号:{logisticsNumber} +

{logisticsDetails || '暂无物流详情'}

+
+ ) + } + onMouseEnter={fetchLogisticsDetails} + > + + {logisticsStatus} + +
+ ); +}); + +// 设置组件显示名称,便于调试 +LogisticsStatus.displayName = 'LogisticsStatus'; + +// 使用React.memo避免不必要的重新渲染 +export default LogisticsStatus; diff --git a/src/components/product/ProductCardList.tsx b/src/components/product/ProductCardList.tsx new file mode 100644 index 0000000..a586089 --- /dev/null +++ b/src/components/product/ProductCardList.tsx @@ -0,0 +1,299 @@ +//src\pages\team\SaleRecord\ProductCardList.tsx +//import React from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; +import { message, Tag } from 'antd'; +import ProductImage from '@/components/product/ProductImage'; +import { IconButton, Iconify } from '@/components/icon'; +import MyTooltip from '@/components/tooltip/MyTooltip'; +import LogisticsStatus from '@/components/logistics/status'; +import { IAfterSalesRecord, IProduct, ISalesRecord } from '@/models/types'; +import { useUserInfo } from '@/store/userStore'; // 使用 Zustand 获取用户信息 +//src\pages\management\product\product-modal.tsx +//src\pages\backstage\product\product-modal.tsx +import ProductModal from '@/pages/backstage/product/product-modal'; + +interface ProductCardListProps { + products: IProduct[]; + //record: ISalesRecord; + //record可以是ISalesRecord类型,也可以是IAfterSalesRecord + record: ISalesRecord | IAfterSalesRecord; + //record: any; +} + +// 将布局封装为可复用的组件 +const ProductCardList: React.FC = ({ products, record }) => { + // 新增状态管理 + const [productModalVisible, setProductModalVisible] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + // 获取用户角色 + const userInfo = useUserInfo(); // 获取当前用户信息 + const userRole = userInfo?.角色?.名称; // 用户角色名称 + const isAdmin = userRole === '系统管理员' || userRole === '团队管理员'; // 是否为管理员角色 + + // 使用useMemo缓存售后产品ID集合 + const afterSalesProductIds = useMemo(() => { + return new Set( + '售后记录' in record && record.售后记录?.flatMap(afterSales => + afterSales.原产品.map(productId => String(productId)) + ) || [] + ); + }, [record]); + + // 优化base64转Blob的函数,避免内存消耗 + const fetchBase64ImageAsBlob = useCallback(async (productId: string): Promise => { + try { + const response = await fetch(`/api/products/images/${productId}`); + if (!response.ok) { + throw new Error(`API请求失败: ${response.status}`); + } + + const data = await response.json(); + if (!data || !data.image) { + throw new Error(`未找到有效的image数据`); + } + + const base64Data = data.image; + // 优化: 检查base64格式并对大型图片进行处理 + if (!base64Data.includes(',')) { + throw new Error(`无效的Base64数据`); + } + + // 使用更高效的blob处理方式 + const response2 = await fetch(base64Data); + return await response2.blob(); + } catch (error) { + console.error(`获取产品${productId}图片失败:`, error); + throw error; + } + }, []); + + // 使用useCallback优化点击处理函数 + const handleCopy = useCallback(async (productId: string) => { + try { + const blob = await fetchBase64ImageAsBlob(productId); + const clipboardItem = new ClipboardItem({ [blob.type]: blob }); + await navigator.clipboard.write([clipboardItem]); + message.success(`产品图片已复制`); + } catch (err) { + console.error('复制失败:', err); + message.error('复制失败'); + } + }, [fetchBase64ImageAsBlob]); + + // 点击编辑按钮的处理函数 + const handleEdit = useCallback((product: IProduct) => { + setEditingProduct(product); + setProductModalVisible(true); + }, []); + + const handleProductModalOk = useCallback(() => { + setProductModalVisible(false); + setEditingProduct(null); + }, []); + + const handleProductModalCancel = useCallback(() => { + setProductModalVisible(false); + setEditingProduct(null); + }, []); + + // 使用条件渲染避免不必要的渲染 + if (!products || products.length === 0) { + return
无产品信息
; + } + + return ( +
+ {products.map(product => { + const isAfterSales = afterSalesProductIds.has(String(product._id)); + const supplierInfo = product.供应商?.联系方式 + ? <> + 联系人: {product.供应商.联系方式.联系人}
+ 电话: {product.供应商.联系方式.电话}
+ 地址: {product.供应商.联系方式.地址} + + : '无供应商信息'; + + return ( +
+ {/* 产品图片 */} +
+ +
+ {/* 编辑和复制按钮 */} + {isAdmin && ( +
+ handleEdit(product)} + > + + + handleCopy(product._id)} + > + + +
+ )} + {/* 供应商名称 */} +
+ +
{product.供应商?.供应商名称}
+
+
+ {/* 产品名称 */} + +
+ {product.名称} +
+
+ {/* 成本价,显示在价格上方 */} +
+ {(() => { + // 计算总成本 = 成本价 + 包装费 + 运费 + const costPrice = product.成本?.成本价 || 0; + const packagingFee = product.成本?.包装费 || 0; + const shippingFee = product.成本?.运费 || 0; + const totalCost = costPrice + packagingFee + shippingFee; + + // 创建成本明细信息 + const costDetails = ( + <> + 成本价: ¥{costPrice}
+ 包装费: ¥{packagingFee}
+ 运费: ¥{shippingFee}
+ 总成本: ¥{totalCost} + + ); + + return totalCost === 0 ? ( + '¥无成本' + ) : ( + + ¥{totalCost.toFixed(2)} + + ); + })()} +
+ {/* 价格 */} +
+ ¥{product.售价} +
+ {/* 物流状态 */} +
+ {isAfterSales && ( +
+ + 已售后 + +
+ )} + +
+
+ ); + })} + {/* 编辑产品弹窗 */} + {productModalVisible && ( + + )} +
+ ); +}; + +// 移除React.memo,避免过度优化导致的刷新问题 +export default ProductCardList; diff --git a/src/components/product/ProductImage.tsx b/src/components/product/ProductImage.tsx new file mode 100644 index 0000000..8c53d1a --- /dev/null +++ b/src/components/product/ProductImage.tsx @@ -0,0 +1,203 @@ +//src\components\product\ProductImage.tsx +import React, { useState, useEffect, useMemo, CSSProperties } from 'react'; +import { Typography, Image } from 'antd'; +import { LoadingOutlined, PictureOutlined } from '@ant-design/icons'; + +const { Text } = Typography; + +interface ProductImageProps { + productId?: string; // productId 变为可选 + alt?: string; // 添加 alt 属性 + width?: number | string; + height?: number | string; + style?: CSSProperties; // 添加 style 属性 +} + +// 添加图片缓存机制 +const imageCache: Record = {}; + +// 批量获取产品图片的函数 +export const batchFetchProductImages = async (productIds: string[]): Promise> => { + try { + const uniqueIds = [...new Set(productIds)]; // 确保ID不重复 + const cachedIds = uniqueIds.filter(id => !imageCache[id]); // 只获取未缓存的ID + + if (cachedIds.length === 0) { + return imageCache; // 如果所有图片已缓存,直接返回缓存 + } + + // 实际API调用可能需要根据后端实现调整 + const response = await fetch(`/api/products/batchImages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ productIds: cachedIds }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const images = data.images || {}; + + // 更新缓存 + Object.entries(images).forEach(([id, image]) => { + if (id && image) { + imageCache[id] = image as string; + } + }); + + return { ...imageCache }; + } catch (error: unknown) { + console.error('批量获取产品图片失败:', error); + return imageCache; + } +}; + +const fetchProductImage = async (productId: string): Promise => { + // 检查缓存 + if (imageCache[productId]) { + return imageCache[productId]; + } + + try { + const response = await fetch(`/api/products/images/${productId}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const image = data.image; + + // 更新缓存 + if (image) { + imageCache[productId] = image; + } + + return image; + } catch (error: unknown) { + console.error(`获取产品图片失败 ID:${productId}`, error); + throw error; + } +}; + +const ProductImage: React.FC = React.memo(({ + productId, + alt = 'Product Image', + width = '100%', + height = 'auto', + style = {} // 默认空对象 +}) => { + const [imageSrc, setImageSrc] = useState(null); // 用于存储图片地址 + const [isLoading, setIsLoading] = useState(false); // 用于管理加载状态 + const [isError, setIsError] = useState(false); // 用于管理错误状态 + + const containerStyle = useMemo(() => ({ + width, + height, + display: 'flex', + flexDirection: 'column' as const, // 确保 flexDirection 为合法类型 + alignItems: 'center', + justifyContent: 'center', + padding: '40px 0', + textAlign: 'center' as const, // 确保 textAlign 为合法类型 + //border: '1px solid #f0f0f0', // 给容器增加轻微边框 + borderRadius: '8px', + ...style // 合并传入的外部样式 + }), [width, height, style]); + + const imageStyle = useMemo(() => ({ + maxWidth: '100%', + maxHeight: '100%', + borderRadius: '8px', + width, + height, + ...style // 合并传入的外部样式 + }), [width, height, style]); + + // 当 productId 存在时,执行图片加载 + useEffect(() => { + if (!productId) return; + + // 如果已缓存,直接使用缓存 + if (imageCache[productId]) { + setImageSrc(imageCache[productId]); + return; + } + + const loadImage = async () => { + setIsLoading(true); + setIsError(false); + try { + const image = await fetchProductImage(productId); + setImageSrc(image); + } catch (error) { + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + loadImage(); + }, [productId]); + + // 当 productId 不存在时显示 "暂无图片" + if (!productId || productId === "default") { + return ( +
+ + 暂无图片 +
+ ); + } + + // 加载中状态 + if (isLoading) { + return ( +
+ + 加载中... +
+ ); + } + + // 加载错误状态,展示 productId + if (isError) { + return ( +
+ + 加载失败 + ID: {productId} +
+ ); + } + + //如果没有imageSrc。则显示请稍后... + if (!imageSrc) { + return ( +
+ + 请稍后... +
+ ); + } + + // 成功加载图片 + return ( + {alt} + ); +}); + +// 设置组件显示名称,便于调试 +ProductImage.displayName = 'ProductImage'; + +export default ProductImage; diff --git a/src/components/tooltip/MyTooltip.module.css b/src/components/tooltip/MyTooltip.module.css new file mode 100644 index 0000000..0156be1 --- /dev/null +++ b/src/components/tooltip/MyTooltip.module.css @@ -0,0 +1,4 @@ +.customTooltip { + color: #1A202C !important; /* 自定义文字颜色,假设 myGray.800 类似 #1A202C */ + --ant-tooltip-arrow-background: white !important; /* 确保箭头颜色和背景一致 */ +} diff --git a/src/components/tooltip/MyTooltip.tsx b/src/components/tooltip/MyTooltip.tsx new file mode 100644 index 0000000..960e10a --- /dev/null +++ b/src/components/tooltip/MyTooltip.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Tooltip, Typography, App } from 'antd'; + +const { Paragraph } = Typography; +interface MyTooltipProps { + title: React.ReactNode; + children: React.ReactNode; + // 自定义 placement 类型,包含所有有效的 placement 选项 + placement?: + | 'top' | 'left' | 'right' | 'bottom' + | 'topLeft' | 'topRight' + | 'bottomLeft' | 'bottomRight' + | 'leftTop' | 'leftBottom' + | 'rightTop' | 'rightBottom'; + shouldWrapChildren?: boolean; + [key: string]: any; // 用于传递额外的 props +} +const MyTooltip: React.FC = ({ + title, + children, + placement = 'topRight', + shouldWrapChildren = false, + ...props +}) => { + // 使用 App.useApp() 获取消息实例 + const { message } = App.useApp(); + + // 提取 title 中的文本内容,并保留格式(例如换行符)的函数 + const extractTextFromTitle = (node: React.ReactNode): string => { + if (typeof node === 'string') { + return node; + } + + if (Array.isArray(node)) { + return node.map(child => extractTextFromTitle(child)).join(''); + } + + if (React.isValidElement(node)) { + if (node.type === 'br') { + return '\n'; // 保留
标签为换行符 + } + // 安全地访问 props.children + if (node.props && typeof node.props === 'object' && 'children' in node.props) { + return extractTextFromTitle(node.props.children as React.ReactNode); + } + } + + return ''; + }; + + // 获取要复制的文本内容 + const getCopyText = () => { + return typeof title === 'string' ? title : extractTextFromTitle(title); + }; + + return ( + message.success('内容已复制到剪贴板'), + tooltips: ['点击复制', '复制成功'] + }} + style={{ + margin: 0, + color: '#1A202C', + fontSize: '12px', + whiteSpace: 'pre-wrap' + }} + > + {title} + + } + placement={placement} + classNames={{ root: "customTooltip" }} + styles={{ + root: { + maxWidth: '960px', + width: 'auto', + }, + body: { + padding: '12px 24px',// 设置内边距 + borderRadius: '8px',// 设置圆角 + whiteSpace: 'pre-wrap',// 设置自动换行 + //color: '#1A202C', // 文本颜色 + fontSize: '12px' // 设置字体大小 + } + }} + getPopupContainer={() => document.body} + {...props} + > + {shouldWrapChildren ? {children} : children} + + ); +}; + +export default MyTooltip; diff --git a/src/pages/api/backstage/sales/Records/[id].ts b/src/pages/api/backstage/sales/Records/[id].ts new file mode 100644 index 0000000..9a60565 --- /dev/null +++ b/src/pages/api/backstage/sales/Records/[id].ts @@ -0,0 +1,99 @@ +// src/pages/api/backstage/sales/Records/[id].ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { SalesRecord } from '@/models'; +import connectDB from '@/utils/connectDB'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query; // 从请求中获取销售记录的 ID + + if (req.method === 'GET') { + try { + const salesRecord = await SalesRecord.findById(id) + .populate({ + path: '导购', + select: '姓名', + }) + .populate({ + path: '客户', + select: '姓名 电话 地址 加粉日期', + }) + .populate({ + path: '产品', + select: '名称 售价 品类 品牌 供应商', + populate: [ + { path: '品类', select: 'name' }, + { path: '品牌', select: 'name' }, + { path: '供应商', select: '供应商名称 联系方式' }, + ], + }) + .populate({ + path: '订单来源', + select: '账号负责人 前端引流人员 账号编号 微信号', + populate: { + path: '账号负责人 前端引流人员', + select: '姓名', + }, + }) + .populate({ + path: '收款平台', + select: '名称', + }) + .populate('团队'); + + if (!salesRecord) { + return res.status(404).json({ message: '未找到销售记录' }); + } + + res.status(200).json({ salesRecord }); + } catch (error) { + res.status(500).json({ message: '服务器错误' }); + } + } else if (req.method === 'PUT') { + try { + const { 客户, 产品, 成交日期, 应收金额, 收款金额, 待收款, 收款平台, 待收已收, 收款状态, 备注, 导购 } = req.body; + + const updatedSalesRecord = await SalesRecord.findByIdAndUpdate( + id, + { + 客户, + 产品, + 成交日期, + 应收金额, + 收款金额, + 待收款, + 收款平台, + 收款状态, + 待收已收, + 备注, + 导购, + }, + { new: true } // 返回更新后的文档 + ); + + if (!updatedSalesRecord) { + return res.status(404).json({ message: '未找到销售记录' }); + } + + res.status(200).json({ message: '销售记录更新成功', salesRecord: updatedSalesRecord }); + } catch (error: any) { + res.status(400).json({ message: '更新销售记录失败', error: error.message }); + } + } else if (req.method === 'DELETE') { + try { + const deletedSalesRecord = await SalesRecord.findByIdAndDelete(id); + + if (!deletedSalesRecord) { + return res.status(404).json({ message: '未找到销售记录' }); + } + + res.status(200).json({ message: '销售记录删除成功' }); + } catch (error: any) { + res.status(500).json({ message: '删除销售记录失败', error: error.message }); + } + } else { + res.setHeader('Allow', ['GET', 'PUT', 'DELETE']); + res.status(405).end(`不允许 ${req.method} 方法`); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/backstage/sales/Records/index.ts b/src/pages/api/backstage/sales/Records/index.ts new file mode 100644 index 0000000..21615cb --- /dev/null +++ b/src/pages/api/backstage/sales/Records/index.ts @@ -0,0 +1,76 @@ +//src\pages\api\backstage\sales\Records\index.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { SalesRecord } from '@/models'; +import connectDB from '@/utils/connectDB'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'GET') { + try { + const { teamId } = req.query; // 获取团队ID + const salesRecords = await SalesRecord.find({ 团队: teamId }) + .populate({ + path: '导购', + select: '姓名' + }) + .populate({ + path: '客户', + select: '姓名 电话 地址 加粉日期' + }) + .populate({ + path: '产品', + //select: '-图片', // 排除产品的图片字段 + select: '名称 售价 成本 品类 品牌 供应商', // 选择需要的产品字段并排除图片 + populate: [ + { path: '品类', select: 'name' }, // 填充品类,选择名称字段 + { path: '品牌', select: 'name' }, // 填充品牌,选择名称字段 + { path: '供应商', select: '供应商名称 联系方式' }, // 填充供应商,选择名称和联系方式字段 + ] + }) + .populate({ + path: '订单来源', + select: '账号负责人 账号编号 微信号', // 选择订单来源中的账号负责人 + populate: { + path: '账号负责人', // 填充账号负责人信息 + select: '姓名' // 仅选择姓名字段 + } + }) + .populate({ + path: '收款平台', + select: '名称', // 选择订单来源中的账号负责人 + }) + .populate({ + path: '优惠券._id', // 使用优惠券子文档中的ID字段进行关联 + select: '优惠券类型 券码 金额 折扣 已使用 使用日期', // 选择优惠券的券码、是否已使用和使用日期字段 + model: 'Coupon' // 关联的模型名称 + }) + .populate({ + path: '余额抵用', // 使用余额抵用子文档中的ID字段进行关联 + select: '类型 金额', // 选择余额抵用的类型和金额字段 + model: 'Transaction' // 关联的模型名称 + }) + .populate({ + path: '售后记录', // 使用余额抵用子文档中的ID字段进行关联 + select: '原产品 售后进度', // 选择余额抵用的类型和金额字段 + }) + + .sort({ createdAt: -1 }); // 按成交日期倒序排列 + res.status(200).json({ salesRecords }); + } catch (error) { + res.status(500).json({ message: '服务器错误' }); + } + } else if (req.method === 'POST') { + try { + const { 客户, 产品, 成交日期, 产品售价, 收款状态, 团队 } = req.body; + const newSalesRecord = new SalesRecord({ 客户, 产品, 成交日期, 产品售价, 收款状态, 团队 }); + await newSalesRecord.save(); + res.status(201).json({ message: '销售记录创建成功', salesRecord: newSalesRecord }); + } catch (error) { + res.status(400).json({ message: '创建销售记录失败' }); + } + } else { + res.setHeader('Allow', ['GET', 'POST']); + res.status(405).end(`不允许 ${req.method} 方法`); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index b51abb6..c7021f1 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken'; import type { NextApiRequest, NextApiResponse } from 'next'; import { User } from '@/models'; import bcrypt from 'bcryptjs'; -import connectDB from '@/utils/ConnectDB'; +import connectDB from '@/utils/connectDB'; import { buildPermissionTree } from './buildPermissionTree'; import type { IPermission } from '@/models/types'; diff --git a/src/pages/api/logistics/batchStatus.ts b/src/pages/api/logistics/batchStatus.ts new file mode 100644 index 0000000..df6f6b7 --- /dev/null +++ b/src/pages/api/logistics/batchStatus.ts @@ -0,0 +1,61 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { LogisticsRecord } from '@/models'; + +const handler = connectDB(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'POST') { + return res.status(405).json({ message: '只允许POST请求' }); + } + + try { + const { recordIds } = req.body; + + if (!recordIds || !Array.isArray(recordIds) || recordIds.length === 0) { + return res.status(400).json({ message: '必须提供记录ID数组' }); + } + + // 限制一次请求的最大记录数量 + const maxBatchSize = 50; + const limitedIds = recordIds.slice(0, maxBatchSize); + + // 批量查询物流状态 + const logisticsRecords = await LogisticsRecord.find( + { 关联记录: { $in: limitedIds } }, + '关联记录 物流单号 物流状态' + ).lean(); + + // 构建id->状态的映射对象 + const statusMap: Record = {}; + + logisticsRecords.forEach(record => { + if (record.关联记录) { + const recordId = record.关联记录.toString(); + statusMap[recordId] = { + 物流状态: record.物流状态 || '处理中', + 物流单号: record.物流单号 + }; + } + }); + + // 为未找到记录的ID设置默认状态 + limitedIds.forEach(id => { + if (!statusMap[id]) { + statusMap[id] = { 物流状态: '待填单' }; + } + }); + + return res.status(200).json({ + success: true, + count: Object.keys(statusMap).length, + statuses: statusMap + }); + } catch (error) { + console.error('批量获取物流状态失败:', error); + return res.status(500).json({ + message: '服务器错误', + error: (error as Error).message + }); + } +}); + +export default handler; \ No newline at end of file diff --git a/src/pages/api/logistics/details.ts b/src/pages/api/logistics/details.ts new file mode 100644 index 0000000..a6a0375 --- /dev/null +++ b/src/pages/api/logistics/details.ts @@ -0,0 +1,60 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { LogisticsRecord } from '@/models'; +import mongoose from 'mongoose'; + +// 定义物流记录类型 +interface ILogisticsRecord { + 物流单号?: string; + 物流公司?: string; + 物流详情?: string; + 物流状态?: string; + 更新时间?: Date; + 产品?: mongoose.Types.ObjectId[]; + _id?: mongoose.Types.ObjectId; +} + +const handler = connectDB(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'GET') { + return res.status(405).json({ message: '只允许GET请求' }); + } + + try { + const { recordId, productId } = req.query; + + if (!recordId) { + return res.status(400).json({ message: '缺少必要参数recordId' }); + } + + // 验证recordId格式 + if (!mongoose.Types.ObjectId.isValid(recordId as string)) { + return res.status(400).json({ message: '无效的recordId格式' }); + } + + // 构建查询条件 + const query: any = { 关联记录: recordId }; + + // 如果提供了productId,添加产品筛选条件 + if (productId && typeof productId === 'string' && mongoose.Types.ObjectId.isValid(productId)) { + query.产品 = productId; + } + + const logisticsRecords = await LogisticsRecord.find(query) + .select('物流单号 物流公司 物流详情 物流状态 更新时间 产品') + .lean() as ILogisticsRecord[]; + + // 设置缓存控制头,允许客户端缓存5分钟 + res.setHeader('Cache-Control', 'public, max-age=300'); + res.setHeader('Expires', new Date(Date.now() + 300000).toUTCString()); + + return res.status(200).json(logisticsRecords); + } catch (error) { + console.error('获取物流详情失败:', error); + return res.status(500).json({ + message: '服务器错误', + error: (error as Error).message + }); + } +}); + +export default handler; \ No newline at end of file diff --git a/src/pages/api/logistics/status.ts b/src/pages/api/logistics/status.ts new file mode 100644 index 0000000..edabf8a --- /dev/null +++ b/src/pages/api/logistics/status.ts @@ -0,0 +1,88 @@ +/** + * 物流状态查询API + * 作者: 阿瑞 + * 功能: 根据记录ID和产品ID获取物流状态 + * 版本: v2.0 + */ +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { LogisticsRecord } from '@/models'; +import mongoose from 'mongoose'; + +// 定义物流记录类型 +interface ILogisticsRecord { + 物流单号?: string; + 物流状态?: string; + _id?: mongoose.Types.ObjectId; +} + +const handler = connectDB(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'GET') { + return res.status(405).json({ message: '只允许GET请求' }); + } + + try { + const { recordId, productId } = req.query; + + // 参数验证 + if (!recordId || typeof recordId !== 'string') { + return res.status(400).json({ message: '缺少必要参数recordId' }); + } + + // 验证recordId格式 + if (!mongoose.Types.ObjectId.isValid(recordId)) { + return res.status(400).json({ message: '无效的recordId格式' }); + } + + // 构建查询条件 + const query: any = { 关联记录: recordId }; + if (productId && typeof productId === 'string' && mongoose.Types.ObjectId.isValid(productId)) { + // 如果提供了有效的productId,可以在查询中添加额外条件 + query.产品 = productId; + } + + // 查询物流记录,使用lean()提高性能 + const logisticsRecord = await LogisticsRecord.findOne(query) + .select('物流单号 物流状态') + .lean() + .exec() as ILogisticsRecord | null; + + // 设置缓存控制头,允许客户端缓存5分钟 + res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=150'); + res.setHeader('Expires', new Date(Date.now() + 300000).toUTCString()); + + // 如果没有找到物流记录,返回"待填单" + if (!logisticsRecord) { + return res.status(200).json({ + data: { + 物流单号: null, + 物流状态: '待填单' + } + }); + } + + // 如果有物流单号但没有物流状态,返回"待发货" + const logisticsStatus = logisticsRecord.物流状态 || (logisticsRecord.物流单号 ? '待发货' : '待填单'); + + return res.status(200).json({ + data: { + 物流单号: logisticsRecord.物流单号, + 物流状态: logisticsStatus + } + }); + } catch (error) { + console.error('获取物流状态失败:', error); + + // 根据错误类型返回不同的状态码 + if ((error as any).name === 'CastError') { + return res.status(400).json({ message: '无效的ID格式' }); + } + + return res.status(500).json({ + message: '获取物流状态失败', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : '服务器内部错误' + }); + } +}); + +export default handler; \ No newline at end of file diff --git a/src/pages/api/products/batchImages.ts b/src/pages/api/products/batchImages.ts new file mode 100644 index 0000000..3366b4b --- /dev/null +++ b/src/pages/api/products/batchImages.ts @@ -0,0 +1,49 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { Product } from '@/models'; + +const handler = connectDB(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'POST') { + return res.status(405).json({ message: '只允许POST请求' }); + } + + try { + const { productIds } = req.body; + + if (!productIds || !Array.isArray(productIds) || productIds.length === 0) { + return res.status(400).json({ message: '必须提供产品ID数组' }); + } + + // 限制一次请求的最大产品数量,防止请求过大 + const maxBatchSize = 50; + const limitedIds = productIds.slice(0, maxBatchSize); + + // 使用MongoDB的$in操作符批量查询 + const products = await Product.find( + { _id: { $in: limitedIds } }, + '图片 _id' + ).lean(); + + // 构建id->图片的映射对象 + const images: Record = {}; + products.forEach(product => { + if (product._id && product.图片) { + images[product._id.toString()] = product.图片; + } + }); + + return res.status(200).json({ + success: true, + count: Object.keys(images).length, + images + }); + } catch (error) { + console.error('批量获取产品图片失败:', error); + return res.status(500).json({ + message: '服务器错误', + error: (error as Error).message + }); + } +}); + +export default handler; \ No newline at end of file diff --git a/src/pages/api/products/images/[id].ts b/src/pages/api/products/images/[id].ts new file mode 100644 index 0000000..e1cf559 --- /dev/null +++ b/src/pages/api/products/images/[id].ts @@ -0,0 +1,57 @@ +/** + * 产品图片获取API + * 作者: 阿瑞 + * 功能: 根据产品ID获取产品图片 + * 版本: v2.0 + */ +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { Product } from '@/models'; + +const handler = connectDB(async (req: NextApiRequest, res: NextApiResponse) => { + // 只允许GET请求 + if (req.method !== 'GET') { + return res.status(405).json({ message: '只允许GET请求' }); + } + + const { id } = req.query; + + // 参数验证 + if (!id || typeof id !== 'string') { + return res.status(400).json({ message: '无效的产品ID' }); + } + + try { + // 设置缓存控制头,允许客户端缓存10分钟 + res.setHeader('Cache-Control', 'public, max-age=600, stale-while-revalidate=300'); + res.setHeader('Expires', new Date(Date.now() + 600000).toUTCString()); + + // 查询产品图片,使用lean()提高性能 + const product = await Product.findById(id, '图片').lean().exec() as { 图片?: string } | null; + + if (!product) { + return res.status(404).json({ message: '产品不存在' }); + } + + // 返回图片数据 + return res.status(200).json({ + image: product.图片 || null, + productId: id + }); + + } catch (error) { + console.error(`获取产品图片失败 ID:${id}`, error); + + // 根据错误类型返回不同的状态码 + if ((error as any).name === 'CastError') { + return res.status(400).json({ message: '无效的产品ID格式' }); + } + + return res.status(500).json({ + message: '获取产品图片失败', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : '服务器内部错误' + }); + } +}); + +export default handler; diff --git a/src/pages/api/roles/[id].ts b/src/pages/api/roles/[id].ts index ff4544c..32f9a04 100644 --- a/src/pages/api/roles/[id].ts +++ b/src/pages/api/roles/[id].ts @@ -1,7 +1,7 @@ //src\pages\api\roles\[id].ts import type { NextApiRequest, NextApiResponse } from 'next'; import { Role } from '@/models'; -import connectDB from '@/utils/ConnectDB'; +import connectDB from '@/utils/connectDB'; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { query: { id }, method } = req; diff --git a/src/pages/api/roles/index.ts b/src/pages/api/roles/index.ts index 8ea8da0..6f6d3cf 100644 --- a/src/pages/api/roles/index.ts +++ b/src/pages/api/roles/index.ts @@ -1,7 +1,7 @@ //src\pages\api\roles\index.ts import type { NextApiRequest, NextApiResponse } from 'next'; import { Role } from '@/models'; -import connectDB from '@/utils/ConnectDB'; +import connectDB from '@/utils/connectDB'; import { buildPermissionTree } from '@/pages/api/buildPermissionTree'; import { IPermission } from '@/models/types'; diff --git a/src/pages/api/team/create.ts b/src/pages/api/team/create.ts index 6b966aa..b56b1ba 100644 --- a/src/pages/api/team/create.ts +++ b/src/pages/api/team/create.ts @@ -1,6 +1,6 @@ //src\pages\api\team\create.ts import { NextApiRequest, NextApiResponse } from 'next'; -import connectDB from '@/utils/ConnectDB'; // 确保数据库连接 +import connectDB from '@/utils/connectDB'; // 确保数据库连接 import { Team, User } from '@/models'; // 引入 Team 和 User 模型 import { ITeam } from '@/models/types'; // 引入类型定义 diff --git a/src/pages/api/team/index.ts b/src/pages/api/team/index.ts index 5ef69ea..154c20f 100644 --- a/src/pages/api/team/index.ts +++ b/src/pages/api/team/index.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { Team } from '@/models'; -import connectDB from '@/utils/ConnectDB'; +import connectDB from '@/utils/connectDB'; const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'GET') { diff --git a/src/pages/api/tools/SFExpress/updateLogisticsDetails.ts b/src/pages/api/tools/SFExpress/updateLogisticsDetails.ts new file mode 100644 index 0000000..07c451a --- /dev/null +++ b/src/pages/api/tools/SFExpress/updateLogisticsDetails.ts @@ -0,0 +1,92 @@ +//src\pages/api/tools/SFExpress/updateLogisticsDetails.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { LogisticsRecord } from '@/models'; +import { querySFExpress } from '@/utils/querySFExpress'; + +const handler = async (_req: NextApiRequest, res: NextApiResponse) => { + try { + // 查询所有需要更新的物流记录,这些记录有有效的物流单号且标记为需要查询 + const logisticsRecords = await LogisticsRecord.find({ 是否查询: true, 物流单号: { $ne: null } }); + + const updateResults = []; // 用于存储每个物流单号和对应的查询结果 + + // 遍历每个物流记录,查询最新的物流信息,并决定是否需要继续查询 + for (const record of logisticsRecords) { + const logisticsDetail = await querySFExpress(record.物流单号, record.客户尾号 || ''); + const details = JSON.parse(logisticsDetail.apiResultData).msgData.routeResps[0].routes; + + const formattedDetails = details.map((detail: { acceptTime: any; acceptAddress: any; remark: any; }) => + `时间: ${detail.acceptTime}, 地点: ${detail.acceptAddress}, 描述: ${detail.remark}` + ); + + // 判断是否需要继续查询物流信息 + const continueQuery = shouldContinueQuery(formattedDetails); + + // 根据物流详情和是否有物流单号来确定物流状态 + const logisticsStatus = determineLogisticsStatus(formattedDetails, !!record.物流单号); + + // 更新物流记录的详细信息、状态及是否继续查询的标志 + await LogisticsRecord.findByIdAndUpdate(record._id, { + 物流详情: formattedDetails.join('\n'), + 物流状态: logisticsStatus, // 新增物流状态字段的更新 + 是否查询: continueQuery, + 更新时间: new Date(), + }); + + updateResults.push({ 物流单号: record.物流单号, 是否继续查询: continueQuery ? '是' : '否', 物流状态: logisticsStatus }); + } + + // 构建响应消息,显示本次查询的结果 + const responseMessage = `本次查询共计${updateResults.length}个:\n` + + updateResults.map((result) => + `物流单号:${result.物流单号},物流状态:${result.物流状态}:继续查询(${result.是否继续查询})`).join('\n') + + res.status(200).json({ message: responseMessage }); + } catch (error: any) { + console.error('Failed to update logistics details:', error); + res.status(500).json({ error: error.message || 'Unknown error' }); + } +}; + +// 连接数据库并导出处理函数 +export default connectDB(handler); + +// 根据物流详情决定是否继续查询的函数 +function shouldContinueQuery(details: string[]): boolean { + const joinedDetails = details.join(" "); + // 使用正则表达式判断物流状态,根据状态确定是否继续查询 + return !(/已收取快件.*发往.*分拣.*派送途中.*拒收.*退回.*签收/.test(joinedDetails) || + /已收取快件.*发往.*分拣.*派送途中.*拒收.*签收/.test(joinedDetails) || + /已收取快件.*发往.*分拣.*派送途中.*签收/.test(joinedDetails)); +} + +// 根据物流详情和是否有物流单号来确定物流状态 +function determineLogisticsStatus(details: string[], hasLogisticsNumber: boolean): string { + if (details.length === 0) { + return hasLogisticsNumber ? '待发货' : '待填单'; // 如果有物流单号但没有详情,则返回“待发货” + } + const joinedDetails = details.join(" "); // 将所有物流详情连成一个长字符串,以便进行模式匹配 + + if (/已收取快件.*发往.*分拣.*派送途中.*拒收.*退回.*签收/.test(joinedDetails)) { + return '已退回'; + } else if (/已收取快件.*发往.*分拣.*派送途中.*拒收.*退回/.test(joinedDetails)) { + return '退回中'; + } else if (/已收取快件.*发往.*分拣.*派送途中.*拒收.*签收/.test(joinedDetails)) { + return '已签收'; + } else if (/已收取快件.*发往.*分拣.*派送途中.*拒收/.test(joinedDetails)) { + return '已拒收'; + } else if (/已收取快件.*发往.*分拣.*派送途中.*签收/.test(joinedDetails)) { + return '已签收'; + } else if (/已收取快件.*发往.*分拣.*派送途中/.test(joinedDetails)) { + return '派送中'; + //} else if (/已收取快件.*发往.*分拣/.test(joinedDetails)) { + // return '分拣中'; + } else if (/已收取快件.*发往/.test(joinedDetails)) { + return '已发出'; + } else if (/已收取快件/.test(joinedDetails)) { + return '已揽收'; + } else { + return '处理中'; // 默认状态 + } +} diff --git a/src/pages/api/tools/logistics/detail/[id].ts b/src/pages/api/tools/logistics/detail/[id].ts new file mode 100644 index 0000000..1a1ec11 --- /dev/null +++ b/src/pages/api/tools/logistics/detail/[id].ts @@ -0,0 +1,27 @@ +// src/pages/api/logistics/detail/[id].ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { LogisticsRecord } from '@/models'; +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query; // 从 URL 中获取订单 ID + if (!id) { + return res.status(400).json({ message: 'Missing logistics record ID.' }); + } + try { + // 根据订单 ID 查找相关的物流记录 + const logisticsRecords = await LogisticsRecord.find({ + 关联记录: id, + }); + // 如果没有找到记录,返回空数组而不是404 + if (!logisticsRecords || logisticsRecords.length === 0) { + return res.status(200).json([]); // 返回空数组表示没有找到记录 + } + // 返回查询到的物流记录 + res.status(200).json(logisticsRecords); + } catch (error: any) { + console.error('Error fetching logistics records:', error); + res.status(500).json({ message: 'Server error', error: error.message }); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/tools/logistics/index.ts b/src/pages/api/tools/logistics/index.ts new file mode 100644 index 0000000..c7c50b9 --- /dev/null +++ b/src/pages/api/tools/logistics/index.ts @@ -0,0 +1,84 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { LogisticsRecord } from '@/models'; + +const handler = connectDB(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'GET') { + const { 关联记录 } = req.query; // 使用req.query获取URL参数 + if (!关联记录) { + return res.status(400).json({ message: '缺少关联记录ID' }); + } + + try { + // 查询所有相关的物流记录 + const logisticsRecords = await LogisticsRecord.find({ 关联记录 }); + + if (logisticsRecords.length > 0) { + return res.status(200).json(logisticsRecords); // 返回多个记录 + } else { + return res.status(404).json({ message: '未找到对应的物流记录' }); + } + } catch (error: any) { + console.error('查询物流记录失败:', error); + return res.status(500).json({ message: '查询物流记录失败,请检查服务器状态', error: error.message }); + } + } + + // 处理 POST 请求的逻辑保持不变 + if (req.method === 'POST') { + const { 关联记录, 类型, 客户尾号, 产品 } = req.body; + + // 确保所有必要的字段都被提供 + if (!关联记录 || !类型 || !客户尾号 || !产品 || !Array.isArray(产品)) { + return res.status(400).json({ message: '缺少必要的字段' }); + } + + try { + // 逐个处理每个产品和物流单号 + const logisticsRecords = await Promise.all(产品.map(async ({ productId, logisticsNumber }) => { + if (!logisticsNumber) { + return null; // 忽略未填写物流单号的产品 + } + + // 查找是否有现有的物流记录(使用$in操作符查询数组字段) + let existingRecord = await LogisticsRecord.findOne({ + 关联记录, + 类型, + 产品: { $in: [productId] } + }); + + if (existingRecord) { + // 更新现有记录 + existingRecord.物流单号 = logisticsNumber; + existingRecord.客户尾号 = 客户尾号; + existingRecord.更新时间 = new Date(); + existingRecord.是否查询 = true; // 修正为boolean类型 + return existingRecord.save(); + } else { + // 创建新记录 - 产品字段应该是数组 + const newLogisticsRecord = await LogisticsRecord.create({ + 物流单号: logisticsNumber, + 客户尾号, + 更新时间: new Date(), + 关联记录, + 类型, + 产品: [productId], // 修正:设置为数组格式 + 是否查询: true // 修正为boolean类型 + }); + return newLogisticsRecord; + } + })); + + return res.status(201).json(logisticsRecords.filter(record => record !== null)); // 返回成功创建或更新的记录 + } catch (error: any) { + console.error('处理物流记录失败:', error); + return res.status(500).json({ message: '处理物流记录失败,请检查提供的数据格式及服务器状态', error: error.message }); + } + } else { + // 如果不是 POST 请求,返回 405 Method Not Allowed + res.setHeader('Allow', ['GET', 'POST']); // 修正:添加GET方法 + res.status(405).end(`Method ${req.method} Not Allowed`); + } +}); + +export default handler; diff --git a/src/pages/api/tools/parseAddress.ts b/src/pages/api/tools/parseAddress.ts new file mode 100644 index 0000000..07e7332 --- /dev/null +++ b/src/pages/api/tools/parseAddress.ts @@ -0,0 +1,133 @@ +/** + * @file: parseAddress.ts + * @author: 阿瑞 + * @description: 地址解析服务API路由 + * @version: 1.0.1 + */ + +// src/pages/api/tools/parseAddress.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import axios from 'axios'; + +interface ExtractedInfoItem { + text: string; + start: number; + end: number; + probability: number; +} + +interface ExtractedInfo { + 姓名?: ExtractedInfoItem[]; + 电话?: ExtractedInfoItem[]; +} + +interface ExtractInfoResponse { + extracted_info: ExtractedInfo[]; +} + +interface ParseLocationResponse { + province: string | null; + city: string | null; + county: string | null; + detail: string | null; + full_location: string | null; + orig_location: string | null; + town: string | null; + village: string | null; +} + +interface CombinedResponse { + name: string | null; + phone: string | null; + address: ParseLocationResponse | null; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + res.status(405).json({ error: '方法不被允许' }); + return; + } + + const { text } = req.body; + + if (!text || typeof text !== 'string') { + res.status(400).json({ error: '请求参数错误,缺少文本内容' }); + return; + } + + try { + // 从环境变量获取 API 的 URL + const extractInfoUrl = process.env.EXTRACT_INFO_API_URL; + const parseLocationUrl = process.env.PARSE_LOCATION_API_URL; + + // 检查环境变量是否配置 + if (!extractInfoUrl || !parseLocationUrl) { + console.error('环境变量未配置:', { + EXTRACT_INFO_API_URL: extractInfoUrl, + PARSE_LOCATION_API_URL: parseLocationUrl + }); + throw new Error('服务配置错误:API地址未设置'); + } + + // 第一步:提取姓名和电话 + const extractInfoResponse = await axios.post( + extractInfoUrl, + { text }, + { headers: { 'Content-Type': 'application/json' } } + ); + + const extractedInfo = extractInfoResponse.data.extracted_info[0]; + + let name: string | null = null; + let phone: string | null = null; + + if (extractedInfo['姓名'] && extractedInfo['姓名'].length > 0) { + name = extractedInfo['姓名'][0].text; + } + + if (extractedInfo['电话'] && extractedInfo['电话'].length > 0) { + phone = extractedInfo['电话'][0].text; + } + + // 从原始文本中移除姓名和电话,得到地址部分 + let addressText = text; + + if (name) { + addressText = addressText.replace(name, '').trim(); + } + + if (phone) { + addressText = addressText.replace(phone, '').trim(); + } + + // 第二步:解析地址 + const parseLocationResponse = await axios.post( + parseLocationUrl, + { text: addressText }, + { headers: { 'Content-Type': 'application/json' } } + ); + + const combinedResponse: CombinedResponse = { + name, + phone, + address: parseLocationResponse.data, + }; + + res.status(200).json(combinedResponse); + } catch (error: any) { + console.error('解析出错:', error.message || error); + // 根据错误类型返回不同的错误信息 + if (error.message === '服务配置错误:API地址未设置') { + res.status(500).json({ error: '服务配置错误,请联系管理员' }); + } else if (axios.isAxiosError(error)) { + res.status(500).json({ + error: `外部服务请求失败: ${error.response?.status || '未知错误'}` + }); + } else { + res.status(500).json({ error: '服务器内部错误' }); + } + } +} diff --git a/src/pages/api/tools/parseAddressKuaidi100.ts b/src/pages/api/tools/parseAddressKuaidi100.ts new file mode 100644 index 0000000..6a790ac --- /dev/null +++ b/src/pages/api/tools/parseAddressKuaidi100.ts @@ -0,0 +1,239 @@ +/** + * 快递100智能地址解析API + * 作者: 阿瑞 + * 功能: 使用快递100 API进行智能地址解析,识别姓名、电话、地址信息 + * 版本: v1.0 + */ +import type { NextApiRequest, NextApiResponse } from 'next'; +import crypto from 'crypto'; + +// 快递100 API响应接口定义 +interface Kuaidi100Response { + success: boolean; + code: number; + message: string; + time: number; + data?: { + taskId: string; + result: Array<{ + content: string; + mobile: string[]; + name: string; + address: string; + xzq: { + fullName: string; + province: string; + city: string; + district: string; + fourth?: string; + subArea: string; + parentCode: string; + code: string; + level: number; + }; + }>; + }; +} + +// 统一响应格式 +interface ParsedAddress { + name: string | null; + phone: string | null; + address: { + province: string | null; + city: string | null; + county: string | null; + detail: string | null; + full_location: string | null; + orig_location: string | null; + town: string | null; + village: string | null; + } | null; +} + +/** + * 生成MD5签名 + * @param param - 参数字符串 + * @param t - 时间戳 + * @param key - API密钥 + * @param secret - API密钥 + * @returns MD5签名(32位大写) + */ +function generateSign(param: string, t: string, key: string, secret: string): string { + const signString = param + t + key + secret; + return crypto.createHash('md5').update(signString).digest('hex').toUpperCase(); +} + +/** + * 调用快递100地址解析API + * @param content - 需要解析的地址内容 + * @returns 解析结果 + */ +async function callKuaidi100API(content: string): Promise { + const key = process.env.KUAIDI100_KEY; + const secret = process.env.KUAIDI100_SECRET; + + if (!key || !secret) { + throw new Error('快递100 API配置缺失,请检查环境变量 KUAIDI100_KEY 和 KUAIDI100_SECRET'); + } + + // 构建请求参数 + const t = Date.now().toString(); + const param = JSON.stringify({ + addressLevel: 4, // 解析到四级地址(街道/乡镇) + content: content.trim() + }); + + // 生成签名 + const sign = generateSign(param, t, key, secret); + + // 构建请求体 + const requestBody = new URLSearchParams({ + key, + sign, + t, + param + }); + + console.log('快递100 API请求参数:', { key, t, param: JSON.parse(param) }); + + try { + // 使用AbortController实现超时控制 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); // 8秒超时 + + const response = await fetch('https://api.kuaidi100.com/address/resolution', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: requestBody.toString(), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP错误: ${response.status} ${response.statusText}`); + } + + const data: Kuaidi100Response = await response.json(); + console.log('快递100 API响应:', data); + + return data; + } catch (error) { + console.error('快递100 API调用失败:', error); + throw error; + } +} + +/** + * 转换快递100响应为统一格式 + * @param kuaidi100Data - 快递100 API响应数据 + * @returns 统一格式的解析结果 + */ +function convertToUnifiedFormat(kuaidi100Data: Kuaidi100Response): ParsedAddress { + if (!kuaidi100Data.success || !kuaidi100Data.data?.result?.length) { + return { + name: null, + phone: null, + address: null + }; + } + + const result = kuaidi100Data.data.result[0]; + const xzq = result.xzq; + + return { + name: result.name || null, + phone: result.mobile?.length > 0 ? result.mobile[0] : null, + address: { + province: xzq.province || null, + city: xzq.city || null, + county: xzq.district || null, + detail: result.address || xzq.subArea || null, + full_location: xzq.fullName || null, + orig_location: result.content || null, + town: xzq.fourth || null, + village: null // 快递100暂不提供村级信息 + } + }; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + // 只允许POST请求 + if (req.method !== 'POST') { + return res.status(405).json({ error: '只支持POST请求' }); + } + + try { + const { text } = req.body; + + // 验证输入参数 + if (!text || typeof text !== 'string' || text.trim().length === 0) { + return res.status(400).json({ error: '请提供有效的地址文本' }); + } + + // 检查文本长度(快递100限制) + if (text.length > 1000) { + return res.status(400).json({ error: '地址文本过长,请控制在1000字符以内' }); + } + + console.log('开始解析地址:', text); + + // 调用快递100 API + const kuaidi100Response = await callKuaidi100API(text); + + // 检查API响应 + if (!kuaidi100Response.success) { + const errorMessage = getErrorMessage(kuaidi100Response.code); + console.error('快递100 API返回错误:', kuaidi100Response); + return res.status(400).json({ + error: `地址解析失败: ${errorMessage} (错误码: ${kuaidi100Response.code})` + }); + } + + // 转换为统一格式 + const parsedResult = convertToUnifiedFormat(kuaidi100Response); + + console.log('地址解析成功:', parsedResult); + return res.status(200).json(parsedResult); + + } catch (error) { + console.error('地址解析API错误:', error); + + if (error instanceof Error) { + if (error.message.includes('配置缺失')) { + return res.status(500).json({ error: error.message }); + } + if (error.message.includes('timeout') || error.message.includes('超时')) { + return res.status(408).json({ error: '请求超时,请稍后重试' }); + } + } + + return res.status(500).json({ error: '地址解析服务暂时不可用,请稍后重试' }); + } +} + +/** + * 根据错误码获取错误信息 + * @param code - 快递100错误码 + * @returns 错误描述 + */ +function getErrorMessage(code: number): string { + const errorMessages: Record = { + [-1]: '解析失败/异常,请稍后重试', + [200]: '提交成功', + [10000]: '解析失败,请检查输入内容', + [10002]: '请求参数错误', + [10007]: '系统内部调用异常', + [10025]: '非法请求,异常文件', + [30002]: '验证签名失败,请检查API配置', + [30004]: '账号单量不足,需要充值' + }; + + return errorMessages[code] || `未知错误 (${code})`; +} \ No newline at end of file diff --git a/src/pages/api/tools/test4.ts b/src/pages/api/tools/test4.ts new file mode 100644 index 0000000..f057ab4 --- /dev/null +++ b/src/pages/api/tools/test4.ts @@ -0,0 +1,51 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import axios from 'axios'; +import connectDB from '@/utils/connectDB'; + +//const extractInfoAPI = 'http://192.168.1.8:8006/extract_info/'; +//const parseLocationAPI = 'http://192.168.1.8:8000/parse_location/'; +// 从环境变量获取 API 的 URL +const extractInfoAPI = process.env.EXTRACT_INFO_API_URL || ''; +const parseLocationAPI = process.env.PARSE_LOCATION_API_URL || ''; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { text } = req.body; + + // 1. 使用 extract_info API 提取信息 + const extractResponse = await axios.post(extractInfoAPI, { text }); + const extractedInfo = extractResponse.data.extracted_info[0]; + + // 提取信息失败时抛出异常 + if (!extractedInfo) { + throw new Error('Failed to extract information'); + } + + // 2. 将城市、县区、详细地址拼接为完整地址 + const city = extractedInfo['城市'][0]?.text || ''; + const county = extractedInfo['县区'][0]?.text || ''; + const detailAddress = extractedInfo['详细地址'][0]?.text || ''; + const fullAddress = `${city}${county}${detailAddress}`; + + // 3. 使用 parse_location API 解析完整地址 + const parseResponse = await axios.post(parseLocationAPI, { text: fullAddress }); + const parsedLocation = parseResponse.data; + + // 4. 返回组合后的结果 + const result = { + ...extractedInfo, + 解析地址: parsedLocation, + }; + + res.status(200).json(result); + } catch (error) { + console.error('Error in processing request:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts index 4d28580..a7c3d8f 100644 --- a/src/pages/api/user.ts +++ b/src/pages/api/user.ts @@ -1,6 +1,6 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { User } from '@/models'; // 用户模型存储在 models 目录中 -import connectDB from '@/utils/ConnectDB'; // 数据库连接工具 +import connectDB from '@/utils/connectDB'; // 数据库连接工具 import { buildPermissionTree } from './buildPermissionTree'; // 存在的权限树构建工具 export default connectDB(async (req: NextApiRequest, res: NextApiResponse) => { diff --git a/src/pages/api/users/[id].ts b/src/pages/api/users/[id].ts index d92f78b..bbd6aeb 100644 --- a/src/pages/api/users/[id].ts +++ b/src/pages/api/users/[id].ts @@ -1,7 +1,7 @@ //src\pages\api\users\[id].ts import type { NextApiRequest, NextApiResponse } from 'next'; import { User } from '@/models'; -import connectDB from '@/utils/ConnectDB'; +import connectDB from '@/utils/connectDB'; import { IUser } from '@/models/types'; // 导入 IUser 接口类型 import bcrypt from 'bcryptjs'; diff --git a/src/pages/api/users/index.ts b/src/pages/api/users/index.ts index d629b21..410a38a 100644 --- a/src/pages/api/users/index.ts +++ b/src/pages/api/users/index.ts @@ -1,7 +1,7 @@ //src\pages\api\users\index.ts import type { NextApiRequest, NextApiResponse } from 'next'; import { User } from '@/models'; -import connectDB from '@/utils/ConnectDB'; +import connectDB from '@/utils/connectDB'; import bcrypt from 'bcryptjs'; const handler = async (req: NextApiRequest, res: NextApiResponse) => { diff --git a/src/pages/backstage/product/index.tsx b/src/pages/backstage/product/index.tsx new file mode 100644 index 0000000..d1a5fa8 --- /dev/null +++ b/src/pages/backstage/product/index.tsx @@ -0,0 +1,284 @@ +// src/pages/test/test8.tsx + +import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import { Card, Row, Col, Button, Tooltip, message, Popconfirm, Select, Space } from 'antd'; +import type { NextPage } from 'next'; +import { useUserInfo } from '@/store/userStore'; // 使用 Zustand 获取用户信息 +import { EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import ProductImage from '@/components/product/ProductImage'; +import { IProduct } from '@/models/types'; +import ProductModal from './product-modal'; + +const { Option } = Select; + +interface ProductsResponse { + products: IProduct[]; +} + +const Test8Page: NextPage = () => { + const [products, setProducts] = useState([]); + const userInfo = useUserInfo(); // 获取当前用户信息 + const [isModalVisible, setIsModalVisible] = useState(false); + const [currentProduct, setCurrentProduct] = useState(null); + + // 新增筛选状态 + const [selectedBrand, setSelectedBrand] = useState(undefined); + const [selectedCategory, setSelectedCategory] = useState(undefined); + const [selectedSupplier, setSelectedSupplier] = useState(undefined); + + useEffect(() => { + const teamId = userInfo.团队?._id; + + if (teamId) { + // 使用 teamId 获取产品数据 + fetch(`/api/backstage/products?teamId=${teamId}`) + .then((res) => { + if (!res.ok) { + throw new Error('网络响应不正常'); + } + return res.json(); + }) + .then((data: ProductsResponse) => { + setProducts(data.products); + }) + .catch((error) => { + console.error('获取产品数据出错:', error); + }); + } + }, [userInfo]); + + const handleModalOk = () => { + setIsModalVisible(false); + if (userInfo.团队?._id) { + // 重新获取产品列表 + // fetchProducts(userInfo.团队._id); + } + }; + + const handleEdit = useCallback((product: IProduct) => { + setCurrentProduct(product); + setIsModalVisible(true); + }, []); + + const handleDelete = useCallback( + async (id: string) => { + try { + const response = await fetch(`/api/backstage/products/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('删除请求失败'); + } + + message.success('产品删除成功'); + // 从列表中移除已删除的产品 + setProducts((prevProducts) => prevProducts.filter((product) => product._id !== id)); + } catch (error) { + message.error('删除产品失败'); + } + }, + [] + ); + + // 从产品数据中提取品牌、品类和供应商列表 + const brands = useMemo(() => { + const brandSet = new Set(); + products.forEach((product) => { + if (product.品牌?.name) { + brandSet.add(product.品牌.name); + } + }); + return Array.from(brandSet); + }, [products]); + + const categories = useMemo(() => { + const categorySet = new Set(); + products.forEach((product) => { + if (product.品类?.name) { + categorySet.add(product.品类.name); + } + }); + return Array.from(categorySet); + }, [products]); + + const suppliers = useMemo(() => { + const supplierSet = new Set(); + products.forEach((product) => { + if (product.供应商?.联系方式?.联系人) { + supplierSet.add(product.供应商.联系方式.联系人); + } + }); + return Array.from(supplierSet); + }, [products]); + + // 实现筛选逻辑 + const filteredProducts = useMemo(() => { + return products.filter((product) => { + const matchesBrand = selectedBrand ? product.品牌?.name === selectedBrand : true; + const matchesCategory = selectedCategory ? product.品类?.name === selectedCategory : true; + const matchesSupplier = selectedSupplier + ? product.供应商?.联系方式?.联系人 === selectedSupplier + : true; + return matchesBrand && matchesCategory && matchesSupplier; + }); + }, [products, selectedBrand, selectedCategory, selectedSupplier]); + + return ( +
+ {/* 筛选器 */} + + + + + + + + + + {filteredProducts.map((product) => { + const fullInfo = `${product.品牌?.name || '无'} ${product.名称}\n售价: ¥${ + product.售价 + }\n供应商: ${product.供应商?.联系方式?.联系人 || '无'}`; + + return ( + + } + actions={[ + , + handleDelete(product._id)} + okText="是" + cancelText="否" + placement="left" + > + + , + ]} + > + {fullInfo}} + placement="top" + > + + {product.品牌?.name || '无'} {product.名称} +
+ } + description={ +
+ + +

+ 售价: ¥{product.售价} +

+ + +

+ 供应商: {product.供应商?.联系方式?.联系人 || '无'} +

+ +
+
+ } + /> + + + + ); + })} + + {isModalVisible && ( + setIsModalVisible(false)} + product={currentProduct} + /> + )} +
+ ); +}; + +export default Test8Page; \ No newline at end of file diff --git a/src/pages/backstage/product/product-modal.tsx b/src/pages/backstage/product/product-modal.tsx new file mode 100644 index 0000000..c463e6a --- /dev/null +++ b/src/pages/backstage/product/product-modal.tsx @@ -0,0 +1,344 @@ +//src\pages\backstage\product\product-modal.tsx +import React, { useEffect, useState, ClipboardEvent, useCallback } from 'react'; +import { + Modal, Form, Input, message, Select, InputNumber, + Row, Col +} from 'antd'; +import { IProduct, IBrand, ICategory, ISupplier } from '@/models/types'; +import { useUserInfo } from '@/store/userStore'; // 使用 Zustand 获取用户信息 +import { CloseOutlined } from '@ant-design/icons'; + +interface ProductModalProps { + visible: boolean; + onCancel: () => void; + onOk: () => void; + product: IProduct | null; +} + +const ProductModal: React.FC = ({ visible, onOk, onCancel, product }) => { + const [form] = Form.useForm(); + const [brands, setBrands] = useState([]); + const [categories, setCategories] = useState([]); + const [suppliers, setSuppliers] = useState([]); + const userInfo = useUserInfo(); // 获取当前用户信息 + + useEffect(() => { + fetchBrandsAndCategoriesAndSuppliers(); + }, []); + + useEffect(() => { + if (product) { + form.setFieldsValue({ + 名称: product.名称, + 描述: product.描述, + 编码: product.编码, + 货号: product.货号, + 品牌: product.品牌?._id, + 品类: product.品类?._id, + 供应商: product.供应商?._id, + 售价: product.售价, + 成本价: product.成本?.成本价, + 包装费: product.成本?.包装费, + 运费: product.成本?.运费, + }); + setProductData({ 图片: product.图片 || '' }); // 初始化图片数据 + } else { + form.resetFields(); // 当没有产品数据时重置表单 + setProductData({ 图片: '' }); // 重置图片数据 + } + }, [product, form]); + + const fetchBrandsAndCategoriesAndSuppliers = async () => { + try { + const teamId = userInfo.团队?._id; + if (!teamId) { + throw new Error('团队ID未找到'); + } + const [brandsResponse, categoriesResponse, suppliersResponse] = await Promise.all([ + fetch(`/api/backstage/brands?teamId=${teamId}`), + fetch(`/api/backstage/categories?teamId=${teamId}`), + fetch(`/api/backstage/suppliers?teamId=${teamId}`) + ]); + + // 检查响应状态 + if (!brandsResponse.ok || !categoriesResponse.ok || !suppliersResponse.ok) { + throw new Error('请求失败'); + } + + // 解析JSON数据 + const [brandsData, categoriesData, suppliersData] = await Promise.all([ + brandsResponse.json(), + categoriesResponse.json(), + suppliersResponse.json() + ]); + + setBrands(brandsData.brands); + setCategories(categoriesData.categories); + setSuppliers(suppliersData.suppliers); + } catch (error) { + message.error('加载数据失败'); + } + }; + + const [productData, setProductData] = useState<{ 图片: string }>({ 图片: '' }); + const handlePaste = useCallback(async (event: ClipboardEvent) => { + event.preventDefault(); + const items = event.clipboardData.items; + for (const item of items) { + if (item.type.indexOf('image') === 0) { + const file = item.getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = (event: ProgressEvent) => { + const base64Image = event.target?.result; + setProductData((prev) => ({ + ...prev, + 图片: base64Image as string, + })); + }; + reader.readAsDataURL(file); + } + break; + } + } + }, []); + + const handleSubmit = useCallback(async () => { + try { + const values = await form.validateFields(); + const method = product ? 'PUT' : 'POST'; + const url = product ? `/api/backstage/products/${product._id}` : '/api/backstage/products'; + // 构建请求数据 + const requestData = { + ...values, + 团队: userInfo.团队?._id, + 图片: productData.图片, + }; + + const response = await fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + throw new Error('请求失败'); + } + + message.success('产品操作成功'); + onOk(); // 直接调用 onOk 通知外部重新加载 + } catch (info) { + console.error('Validate Failed:', info); + message.error('产品操作失败'); + } + }, [form, product, onOk, userInfo, productData]); + + return ( + { + form.resetFields(); + onCancel(); + }} + width='80%' + > +
+ + + {/* 第一行:产品名称 */} + + + + + + + + + {/* 第二行:产品描述、产品货号、产品编码 */} + + + + + + + + + + + + + + + + + + + {/* 第三行:供应商、品牌、品类 */} + + + + + + + + + + + + + + + + + + + {/* 第四行:售价、成本价、包装费、运费 */} + + + + + + + + + + + + + + + + + + + + + + + + + {/* 右侧图片区域 */} + + +
+ {productData.图片 ? ( + <> + Product Image + + + ) : ( +
+

粘贴图片到此区域

+

请上传小于800*800px分辨率图片

+
+ )} +
+
+ +
+
+
+ ); +}; + +export default ProductModal; \ No newline at end of file diff --git a/src/pages/team/SaleRecord/AfterSalesModal.tsx b/src/pages/team/SaleRecord/AfterSalesModal.tsx new file mode 100644 index 0000000..654d5c6 --- /dev/null +++ b/src/pages/team/SaleRecord/AfterSalesModal.tsx @@ -0,0 +1,847 @@ +/** + * 作者: 阿瑞 + * 功能: 售后记录管理模态框 + * 版本: 1.0.0 + */ +import React, { useEffect, useState } from 'react'; +import { + Modal, + Form, + Input, + Select, + DatePicker, + Button, + InputNumber, + message, + Row, + Col, + Divider, + Space, + Typography, + Tooltip, + Card, + Empty, + Tag, + Badge +} from 'antd'; +import { + CloseOutlined, + UserOutlined, + PhoneOutlined, + CalendarOutlined, + DollarOutlined, + SwapOutlined, + RollbackOutlined, + SendOutlined, + DiffOutlined, + PictureOutlined, + UploadOutlined, + BankOutlined, + InfoCircleOutlined, + FileTextOutlined, + QuestionCircleOutlined, + CheckCircleOutlined +} from '@ant-design/icons'; +import { ISalesRecord, IPaymentPlatform, IProduct } from '@/models/types'; +import { useUserInfo } from '@/store/userStore'; +import ProductImage from '@/components/product/ProductImage'; +//import AddProductComponent from '../sale/components/AddProductComponent'; + +const { Option } = Select; +const { Text } = Typography; + +/** + * 售后记录模态框组件属性定义 + */ +interface AfterSalesModalProps { + visible: boolean; + onOk: () => void; + onCancel: () => void; + record: ISalesRecord | null; + type: '退货' | '换货' | '补发' | '补差'; +} + +/** + * 返回与售后类型相关的图标和颜色 + */ +const getTypeConfig = (type: '退货' | '换货' | '补发' | '补差') => { + switch (type) { + case '退货': + return { icon: , color: '#ff4d4f', text: '退货处理' }; + case '换货': + return { icon: , color: '#1890ff', text: '换货处理' }; + case '补发': + return { icon: , color: '#52c41a', text: '补发处理' }; + case '补差': + return { icon: , color: '#faad14', text: '差价处理' }; + default: + return { icon: , color: '#999', text: '售后处理' }; + } +}; + +/** + * 售后记录模态框组件 + */ +const AfterSalesModal: React.FC = ({ visible, onOk, onCancel, record, type }) => { + const [form] = Form.useForm(); + const [paymentPlatforms, setPaymentPlatforms] = useState([]); + const [selectedProducts, setSelectedProducts] = useState([]); + const [products, setProducts] = useState([]); + const [paymentCode, setPaymentCode] = useState(null); + const [isProductModalVisible, setIsProductModalVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const userInfo = useUserInfo(); + const typeConfig = getTypeConfig(type); + + /** + * 模块级注释:初始化数据加载 + * 当组件可见或记录/类型变更时加载相关数据 + */ + useEffect(() => { + if (visible && record && userInfo.团队?._id) { + setLoading(true); + const teamId = userInfo.团队._id; + + // 重置表单和状态 + form.resetFields(); + setSelectedProducts([]); + setPaymentCode(null); + + // 并行加载数据 + Promise.all([ + fetchPayPlatforms(teamId), + fetchProducts(teamId) + ]).finally(() => { + // 设置默认表单值 + form.setFieldsValue({ + 销售记录: record._id, + 类型: type, + 日期: new Date(), + 收支金额: type === '退货' ? record.收款金额 : 0, + 收支类型: type === '退货' ? '支出' : (type === '补差' ? '收入' : ''), + }); + setLoading(false); + }); + } + }, [record, type, visible]); + + /** + * 获取收支平台数据 + */ + const fetchPayPlatforms = async (teamId: string) => { + try { + const response = await fetch(`/api/backstage/payment-platforms?teamId=${teamId}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + setPaymentPlatforms(data.platforms || []); + return data.platforms; + } catch (error: unknown) { + console.error('加载平台数据失败:', error); + message.error('加载平台数据失败'); + setPaymentPlatforms([]); + return []; + } + }; + + /** + * 获取产品数据 + */ + const fetchProducts = async (teamId: string) => { + try { + const response = await fetch(`/api/backstage/products?teamId=${teamId}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + setProducts(data.products || []); + return data.products; + } catch (error: unknown) { + console.error('加载产品数据失败:', error); + message.error('加载产品数据失败'); + return []; + } + }; + + /** + * 处理表单提交 + */ + const handleOk = async () => { + try { + setLoading(true); + const values = await form.validateFields(); + + if (!record) { + message.error('未选择销售记录,无法创建售后记录'); + return; + } + + // 验证售后类型特定的必填项 + if ((type === '换货' || type === '补发') && selectedProducts.length === 0) { + message.error(`请选择${type}产品`); + return; + } + + const originalProductIds = values.原产品; + const replacementProductIds = selectedProducts.map(product => product._id); + + // 处理收款码上传 + let paymentCodeId = null; + if (paymentCode) { + const paymentCodeResponse = await fetch('/api/backstage/sales/aftersale/uploadPaymentCode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ 收款码: paymentCode }) + }); + + if (paymentCodeResponse.ok) { + const paymentCodeData = await paymentCodeResponse.json(); + paymentCodeId = paymentCodeData.paymentCodeId; + } else { + throw new Error('收款码上传失败'); + } + } + + // 构建售后数据 + const afterSalesData = { + ...values, + 销售记录: record._id, + 原产品: originalProductIds, + 替换产品: replacementProductIds, + 类型: type, + 团队: userInfo.团队?._id, + 日期: values.日期.toISOString(), + 收款码: paymentCodeId, + 售后进度: '待处理', + }; + + const afterSalesResponse = await fetch('/api/backstage/sales/aftersale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(afterSalesData) + }); + + if (!afterSalesResponse.ok) { + throw new Error(`HTTP error! status: ${afterSalesResponse.status}`); + } + + message.success({ + content: `${type}记录创建成功`, + icon: + }); + onOk(); + } catch (error: unknown) { + console.error(`${type}操作失败:`, error); + message.error(`${type}操作失败,请检查表单数据`); + } finally { + setLoading(false); + } + }; + + /** + * 处理添加产品成功后的回调 + */ + const handleAddProductSuccess = (newProduct: IProduct) => { + setSelectedProducts(prevProducts => [...prevProducts, newProduct]); + setIsProductModalVisible(false); + }; + + /** + * 处理产品选择变更 + */ + const handleProductSelectChange = (selectedProductIds: string[]) => { + const selected = products.filter(product => selectedProductIds.includes(product._id)); + setSelectedProducts(selected); + }; + + /** + * 处理剪贴板粘贴图片 + */ + const handlePaste = (event: React.ClipboardEvent) => { + const items = event.clipboardData.items; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = async (e) => { + const img = new Image(); + img.src = e.target?.result as string; + img.onload = async () => { + // 计算压缩后的尺寸和质量 + const maxSize = 800; + const ratio = Math.min(maxSize / img.width, maxSize / img.height, 1); + const width = img.width * ratio; + const height = img.height * ratio; + + // 压缩图片 + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = width; + canvas.height = height; + ctx?.drawImage(img, 0, 0, width, height); + const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.7); + + setPaymentCode(compressedDataUrl); + message.success('图片已添加'); + }; + }; + reader.readAsDataURL(file); + } + } + } + }; + + /** + * 渲染替换产品表单区域 + */ + const renderReplacementProductsSection = () => { + if (type === '换货' || type === '补发') { + return ( +
+ + {type === '换货' ? : } + {type === '换货' ? '替换产品' : '补发产品'} + + } + extra={ + + } + style={{ marginBottom: 16 }} + > + {selectedProducts.length > 0 ? ( +
+ {selectedProducts.map(product => ( + + +
+ } + actions={[ +
+ ) : ( + + )} + + { + if (selectedProducts.length === 0) { + return Promise.reject(`请选择${type === '换货' ? '替换' : '补发'}产品`); + } + return Promise.resolve(); + } + } + ]} + > + + + + + ); + } + return null; + }; + + return ( + + + {typeConfig.icon} + + {typeConfig.text} + {record && ( + {record.客户?.姓名} + )} + + } + onCancel={onCancel} + footer={[ + , + , + ]} + width="90%" + styles={{ + body: { maxHeight: '75vh', overflow: 'auto', padding: '16px 24px' } + }} + maskClosable={false} + destroyOnClose={true} + style={{ top: "10%" }} + > +
+ + {/* 左侧:原订单信息 */} + + + + 原订单信息 + + } + size="small" + bordered + style={{ marginBottom: 16 }} + > + {record && ( +
+ + + + + {record.客户?.姓名 || '未知'} + + + + + + {record.客户?.电话 ? `${record.客户.电话.slice(0, 3)}****${record.客户.电话.slice(-4)}` : '无'} + + + + + + + + + 成交日期: {new Date(record.成交日期).toLocaleDateString('zh-CN')} + + + + + + 应收: ¥{record.应收金额?.toFixed(2)} + + + + + + + + + 收款: ¥{record.收款金额?.toFixed(2)} + + + + + + 待收: ¥{record.待收款?.toFixed(2)} + + + + + + + + + + 发起售后: + + 在成交 {Math.ceil((new Date().getTime() - new Date(record.成交日期).getTime()) / (1000 * 60 * 60 * 24))}天后发起售后 + + + +
+ )} +
+ + + + 选择售后产品 + + } + size="small" + bordered + > + + + + + + + + + + } + getPopupContainer={triggerNode => triggerNode.parentElement || document.body} + /> + + + + + {/* 中间:替换产品信息 */} + + {renderReplacementProductsSection()} + + + + 备注信息 + + } + size="small" + bordered + > + + + + + + + {/* 右侧:财务信息和收款码 */} + + + + 收支信息 + + } + size="small" + bordered + style={{ marginBottom: 16 }} + > + + + + + + + + + + + + + + + + + + 收支金额 + + + + } + rules={[ + { required: true, message: '请输入金额' } + ]} + > + } + placeholder="请输入金额" + /> + + + + + + 待收 + + + + } + name="待收" + > + } + placeholder="待收金额" + /> + + + + + + + + 收款码 + + } + size="small" + bordered + > + +
{ + e.currentTarget.style.borderColor = '#1890ff'; + e.currentTarget.style.background = '#f0f5ff'; + }} + onMouseOut={(e) => { + e.currentTarget.style.borderColor = '#d9d9d9'; + e.currentTarget.style.background = '#fafafa'; + }} + > + {paymentCode ? ( +
+ Payment Code +
+ ) : ( +
+

+

粘贴图片到此区域

+

支持 Ctrl+V 粘贴收款码截图

+
+ )} +
+
+
+ +
+
+ + {/* 产品添加模态框 + setIsProductModalVisible(false)} + onSuccess={handleAddProductSuccess} + /> */} +
+ ); +}; + +export default AfterSalesModal; diff --git a/src/pages/team/SaleRecord/index.tsx b/src/pages/team/SaleRecord/index.tsx new file mode 100644 index 0000000..29cb217 --- /dev/null +++ b/src/pages/team/SaleRecord/index.tsx @@ -0,0 +1,752 @@ +/** + * 作者: 阿瑞 + * 功能: 销售记录管理页面 + * 版本: 2.0.0 (优化版) + */ +import React, { useState, useEffect, useMemo, lazy, Suspense } from "react"; +import { + Table, + Button, + TableColumnType, + Tag, + Tooltip, + Skeleton, + Typography, + App, +} from "antd"; + +const { Paragraph } = Typography; +const { useApp } = App; + +// 常量定义 +const AFTER_SALES_TYPES = ['退货', '换货', '补发', '补差'] as const; +const ADMIN_ROLES = ['系统管理员', '团队管理员']; +const CUSTOMER_TAG_COLORS = { + hasDebt: "#cd201f", + normal: "blue" +}; + +// 通用样式 +const COMMON_STYLES = { + tagContainer: { margin: 0 }, + flexColumn: { display: "flex", flexDirection: "column" as const, alignItems: "flex-start", gap: "4px" }, + flexRow: { display: "flex", gap: "8px" }, + copyButton: { color: "#fa8c16", padding: 6, marginTop: "4px" }, + scrollContainer: { + width: '100%', + maxWidth: '410px', + overflowX: 'auto' as const, + overflowY: 'hidden' as const, + scrollbarWidth: 'thin' as const, + scrollbarColor: '#d9d9d9 transparent', + } +}; +import { ISalesRecord, ICoupon } from "@/models/types"; // 确保有正确的类型定义 +import { useUserInfo } from "@/store/userStore"; // 使用 Zustand 获取用户信息 +import { + FieldTimeOutlined, + MobileOutlined, + UserOutlined, + WechatOutlined, +} from "@ant-design/icons"; +import { IconButton, Iconify } from "@/components/icon"; +import MyTooltip from "@/components/tooltip/MyTooltip"; +import ProductCardList from "@/components/product/ProductCardList"; + +// 导出Excel相关库 + +// 工具函数 +const formatDate = (dateString: string): string => { + if (!dateString) return "未知"; + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).replace(/\//g, '-'); +}; + +const formatDateWithoutYear = (dateString: string): string => { + if (!dateString) return "未知"; + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + month: '2-digit', + day: '2-digit' + }).replace(/\//g, '-'); +}; + +const formatDateTimeWithoutYear = (dateString: string): string => { + if (!dateString) return "未知"; + const date = new Date(dateString); + const dateStr = date.toLocaleDateString('zh-CN', { + month: '2-digit', + day: '2-digit' + }).replace(/\//g, '-'); + const timeStr = date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + return `${dateStr} ${timeStr}`; +}; + +const calculateDaysDiff = (endDate: Date, startDate: Date | null): string | number => { + if (!startDate) return '未知'; + return Math.ceil(Math.abs(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); +}; + +const getCustomerTagColor = (unreceivedAmount: number): string => { + return unreceivedAmount > 0 ? CUSTOMER_TAG_COLORS.hasDebt : CUSTOMER_TAG_COLORS.normal; +}; + +const buildCustomerAddress = (address: any): string => { + if (!address) return "未知"; + return `${address.省份 ?? ""} ${address.城市 ?? ""} ${address.区县 ?? ""} ${address.详细地址 ?? ""}`.trim(); +}; + +// 使用 React.lazy 延迟加载这些组件 +const EditSalesModal = lazy(() => import("./sales-modal")); +const AfterSalesModal = lazy(() => import("./AfterSalesModal")); +const ShipModal = lazy(() => import("./ship-modal")); + +const SalesPage = () => { + const { message } = useApp(); + const userInfo = useUserInfo(); + + // 状态管理 + const [modals, setModals] = useState({ + edit: false, + ship: false, + afterSales: false, + }); + const [currentRecord, setCurrentRecord] = useState(null); + const [loading, setLoading] = useState(false); + const [salesRecords, setSalesRecords] = useState([]); + const [afterSalesType, setAfterSalesType] = useState(null); + + // 计算衍生状态 + const userRole = userInfo?.角色?.名称; + const isAdmin = useMemo(() => ADMIN_ROLES.includes(userRole || ''), [userRole]); + const isNotFinanceRole = useMemo(() => userRole !== "财务", [userRole]); + + // 模态框处理函数 + const modalHandlers = useMemo(() => ({ + showEdit: (record: ISalesRecord) => { + setCurrentRecord(record); + setModals(prev => ({ ...prev, edit: true })); + }, + showShip: (record: ISalesRecord) => { + setCurrentRecord(record); + setModals(prev => ({ ...prev, ship: true })); + }, + showAfterSales: (record: ISalesRecord, type: typeof AFTER_SALES_TYPES[number]) => { + setCurrentRecord(record); + setAfterSalesType(type); + setModals(prev => ({ ...prev, afterSales: true })); + }, + closeAll: () => { + setModals({ edit: false, ship: false, afterSales: false }); + setCurrentRecord(null); + setAfterSalesType(null); + }, + handleSuccess: () => { + if (userInfo.团队?._id) { + fetchSalesRecords(userInfo.团队._id); + } + } + }), [userInfo.团队?._id]); + + useEffect(() => { + if (userInfo.团队?._id) { + fetchSalesRecords(userInfo.团队._id); + } + }, [userInfo]); // 恢复原来的依赖项,只在userInfo变化时重新获取数据 + + + + useEffect(() => { + if (salesRecords.length > 0) { + // 预加载产品图片和物流状态 + const productIds = new Set(); + const recordIds = new Set(); + + salesRecords.forEach(record => { + if (record._id) recordIds.add(record._id); + if (record.产品 && record.产品.length > 0) { + record.产品.forEach(product => { + if (product._id) productIds.add(product._id); + }); + } + }); + + // 批量预加载产品图片 + if (productIds.size > 0) { + fetch('/api/products/batchImages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + productIds: Array.from(productIds) + }) + }).then(async (response) => { + if (response.ok) { + const data = await response.json(); + // 存储图片到本地缓存 + const imageData = data?.images || {}; + localStorage.setItem('productImageCache', JSON.stringify(imageData)); + console.log('预加载产品图片成功'); + } else { + throw new Error(`HTTP error! status: ${response.status}`); + } + }).catch((error: Error) => { + console.error('预加载产品图片失败', error); + }); + } + + // 批量预加载物流状态 + if (recordIds.size > 0) { + fetch('/api/logistics/batchStatus', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recordIds: Array.from(recordIds) + }) + }).then(async (response) => { + if (response.ok) { + const data = await response.json(); + // 存储物流状态到本地缓存 + const statusData = data?.statuses || {}; + localStorage.setItem('logisticsStatusCache', JSON.stringify(statusData)); + console.log('预加载物流状态成功'); + } else { + throw new Error(`HTTP error! status: ${response.status}`); + } + }).catch((error: Error) => { + console.error('预加载物流状态失败', error); + }); + } + } + }, [salesRecords]); + + // 获取销售记录 + const fetchSalesRecords = async (teamId: string) => { + setLoading(true); // 请求开始前设置加载状态为 true + try { + const response = await fetch( + `/api/backstage/sales/Records?teamId=${teamId}` + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const records = data.salesRecords || []; + + setSalesRecords(records); + setLoading(false); + } catch (error) { + console.error('获取销售记录失败:', error); + message.error("加载销售记录失败"); // 处理错误 + } finally { + setLoading(false); // 无论成功还是失败都确保加载状态被设置为 false + } + }; + + // 已迁移到modalHandlers中 + //const columns: TableColumnType[] = useMemo(() => [ + const columns: TableColumnType[] = useMemo(() => { + const baseColumns: TableColumnType[] = [ + { + title: "来源/导购/日期", + width: 180, + key: "综合信息", + align: "center", + render: (record: any) => { + const wechatId = record.订单来源?.微信号 ?? "未知"; + const accountNumber = record.订单来源?.账号编号 ?? "未知"; + const guideName = record.导购?.姓名 ?? "未知"; + const transactionDate = formatDateWithoutYear(record.成交日期); + const createdDateTime = formatDateTimeWithoutYear(record.createdAt); + + return ( +
+ {/* 第一行:导购 */} + } + color="green" + style={{ + ...COMMON_STYLES.tagContainer, + fontWeight: 'bold', + marginBottom: '3px', + fontSize: '13px' + }} + > + {guideName} + + + {/* 第二行:账号编号和微信号 */} +
+ } + color="blue" + style={{ + margin: 0, + fontSize: '12px', + padding: '1px 4px' + }} + > + {accountNumber} + + } + color="cyan" + style={{ + margin: 0, + fontSize: '12px', + padding: '1px 6px' + }} + > + {wechatId} + +
+ + {/* 第三行:成交日期 */} + } + color="orange" + style={{ + margin: 0, + fontSize: '12px', + marginBottom: '2px', + padding: '1px 6px' + }} + > + 成交日期: {transactionDate} + + + {/* 第四行:创建日期时间 */} + } + color="purple" + style={{ + margin: 0, + fontSize: '12px', + padding: '1px 6px' + }} + > + 创建: {createdDateTime} + +
+ ); + }, + }, + { + title: "客户信息", + width: 60, + align: "center", + key: "客户信息", + render: (record: any) => { + const address = buildCustomerAddress(record.客户?.地址); + const customerName = record.客户?.姓名 ?? "未知"; + const transactionDate = record.成交日期 ? new Date(record.成交日期) : new Date(); + const addFansDate = record.客户?.加粉日期 ? new Date(record.客户.加粉日期) : null; + const diffDays = calculateDaysDiff(transactionDate, addFansDate); + const unreceivedAmount = parseFloat((record.待收款 || 0).toFixed(2)); + const customerTagColor = getCustomerTagColor(unreceivedAmount); + + // 准备复制文本 + const customerCopyText = `姓名:${record.客户?.姓名 ?? "未知"}\n电话:${record.客户?.电话 ?? "未知"}\n地址:${address}`; + + return ( +
+ {/* 左侧复制按钮 */} +
+ {isAdmin && ( + message.success(`客户 ${customerName} 信息复制成功!`), + tooltips: ['复制客户信息', '复制成功'], + icon: + }} + style={{ margin: 0, lineHeight: 0 }} + > + {/* 空内容,只显示复制按钮 */} + + )} +
+ + {/* 右侧标签信息,每行一个标签 */} + +
+ } color={customerTagColor} style={COMMON_STYLES.tagContainer}> + {customerName} + + } color="green" style={COMMON_STYLES.tagContainer}> + {record.客户?.电话 ? `${record.客户.电话.slice(-4)}` : "未知"} + + } color="purple" style={COMMON_STYLES.tagContainer}> + {record.客户?.加粉日期 ? formatDate(record.客户.加粉日期) : "未知"} + + + 成交周期:{diffDays}天 + +
+
+
+ ); + }, + }, + //备注 + { + title: "备注", + key: "备注", + dataIndex: "备注", + width: 260, + render: (text: string, record: any) => { + // 获取产品列表 + const products = record.产品 || []; + const fetchBase64ImageAsBlob = async ( + productId: string + ): Promise => { + const response = await fetch(`/api/products/images/${productId}`); + const data = await response.json(); + + if (!data || !data.image) { + throw new Error(`未找到有效的 image 数据,产品ID: ${productId}`); + } + + const base64Data = data.image; + if (!base64Data.includes(",")) { + throw new Error(`无效的 Base64 数据,产品ID: ${productId}`); + } + + const byteCharacters = atob(base64Data.split(",")[1]); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray], { type: "image/png" }); + }; + + + + // 准备纯文本复制内容 + const customerName = record.客户?.姓名 ?? "未知"; + const tail = record.客户?.电话 ? record.客户.电话.slice(-4) : "****"; + const remark = text || "无备注"; + + // 生成所有产品的文本内容 + let productText = ""; + if (products.length > 0) { + products.forEach((product: any, index: number) => { + productText += `【产品${index + 1}】${product.名称 || "未知产品"}\n`; + }); + } else { + productText = "【产品】无产品\n"; + } + + const combinedText = `【客户】${customerName}-${tail}\n${productText}【备注】${remark}`; + + return ( +
+
+ + { + try { + if (products.length > 0) { + try { + // 尝试复制第一款产品的图片 + const productId = products[0]._id; + const blob = await fetchBase64ImageAsBlob(productId); + + const clipboardItems: Record = { + "text/plain": new Blob([combinedText], { type: "text/plain" }), + }; + clipboardItems[blob.type] = blob; + + const clipboardItem = new ClipboardItem(clipboardItems); + await navigator.clipboard.write([clipboardItem]); + message.success("客户信息、所有产品名称、备注和图片已复制"); + } catch (imageError) { + // 图片复制失败,降级到文本复制 + console.error("图片复制失败,仅复制文本信息:", imageError); + await navigator.clipboard.writeText(combinedText); + message.success("客户信息、所有产品名称和备注已复制"); + } + } else { + // 没有产品时只复制文本 + await navigator.clipboard.writeText(combinedText); + message.success("客户信息、备注已复制"); + } + } catch (err) { + console.error("复制失败:", err); + message.error("复制信息失败"); + } + }} + > + + + +
+ + {/* 备注内容 */} + +
+ {text || "无备注"} +
+
+
+ ); + }, + }, + //使用替换,显示产品信息 + { + title: "产品信息", + width: 420, // 增加宽度以适应3个产品 (130px * 3 + 间距) + dataIndex: "产品", + key: "productImage", + render: (products: any[], record: ISalesRecord) => ( +
+ +
+ ), + }, + { + title: "财务信息", + key: "financialInfo", + width: 180, // 适当调整宽度 + render: (record: any) => { + const paymentPlatform = record.收款平台?.名称 ?? "未知"; + const receivedAmount = record.收款金额 + ? record.收款金额.toFixed(2) + : "0.00"; + const paymentStatus = record.收款状态 ?? "未知"; + const receivableAmount = record.应收金额 + ? record.应收金额.toFixed(2) + : "0.00"; + const unreceivedAmount = record.待收款 + ? record.待收款.toFixed(2) + : "0.00"; + const receivedPendingAmount = record.待收已收 + ? record.待收已收.toFixed(2) + : "0.00"; // 待收已收字段 + + return ( +
+
+ {paymentPlatform} + + 应收: ¥{receivableAmount} + +
+
+ {paymentStatus} + + 收款: ¥{receivedAmount} + +
+
+ 余额抵用: + + ¥{record.余额抵用?.金额 ?? 0} + +
+
+ 优惠券: +
+ {record.优惠券?.map((coupon: ICoupon) => ( + + 类型:{coupon._id.优惠券类型} +
+ 金额:{coupon._id.金额} +
+ 折扣:{coupon._id.折扣} + + } + > + + {coupon._id.优惠券类型} + +
+ ))} +
+
+ +
+ + 0 + ? "#ff4d4f" + : "inherit", // 只有大于0时才显示红色 + }} + > + 待收:¥{unreceivedAmount} + + +
+ 已收: + + ¥{receivedPendingAmount} + +
+
+
+ ); + }, + }, + ]; + + // 如果用户不是财务角色,添加操作列 + if (isNotFinanceRole) { + baseColumns.push({ + title: "操作", + key: "action", + align: "center", + fixed: "right", + width: 160, + render: (_: any, record: ISalesRecord) => ( +
+
+ {AFTER_SALES_TYPES.map((type) => ( + + + + ))} +
+
+ + +
+
+ ), + }); + } + + return baseColumns; + }, [isNotFinanceRole, isAdmin, userInfo]); + + return ( + // 外层容器 - 占据全部可用空间 +
+ `共 ${total} 条记录`, + size: 'small' + }} + size="small" + loading={loading} + columns={columns} + dataSource={salesRecords} + rowKey="_id" + // 添加行渲染优化 + rowClassName={(_, index) => (index % 2 === 0 ? 'even-row' : 'odd-row')} + // 表格样式 - 确保占满容器 + style={{ + height: '100%', + width: '100%' + }} + /> + }> + {modals.edit && ( + { + modalHandlers.closeAll(); + modalHandlers.handleSuccess(); + }} + onCancel={modalHandlers.closeAll} + record={currentRecord} + /> + )} + {modals.afterSales && ( + { + modalHandlers.closeAll(); + modalHandlers.handleSuccess(); + }} + onCancel={modalHandlers.closeAll} + record={currentRecord} + type={afterSalesType!} + /> + )} + {modals.ship && ( + + )} + + + ); +}; + +export default SalesPage; diff --git a/src/pages/team/SaleRecord/sales-modal.tsx b/src/pages/team/SaleRecord/sales-modal.tsx new file mode 100644 index 0000000..a3ef1dd --- /dev/null +++ b/src/pages/team/SaleRecord/sales-modal.tsx @@ -0,0 +1,565 @@ +/** + * 作者: 阿瑞 + * 功能: 销售记录编辑模态框 + * 版本: 1.0.0 + */ +import React, { useEffect, useState } from 'react'; +import { + Modal, + Form, + Input, + Select, + DatePicker, + Button, + message, + InputNumber, + Row, + Col, + Space, + Spin, + Typography, + Tooltip, + Card} from 'antd'; +import { + UserOutlined, + ShoppingCartOutlined, + CalendarOutlined, + DollarOutlined, + BankOutlined, + CreditCardOutlined, + PictureOutlined +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { ISalesRecord, ICustomer, IProduct, IPaymentPlatform } from '@/models/types'; +import axios from 'axios'; +import { useUserInfo } from '@/store/userStore'; + +const { Title, Text } = Typography; +const { Option } = Select; + +interface SalesModalProps { + visible: boolean; + onOk: () => void; + onCancel: () => void; + record?: ISalesRecord | null; +} + +/** + * 产品选项组件,显示图片、名称和价格 + */ +const ProductSelectOption = ({ product }: { product: IProduct }) => { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageSrc, setImageSrc] = useState(null); + + // 加载产品图片 + useEffect(() => { + if (!product._id) return; + + const fetchImage = async () => { + try { + const response = await axios.get(`/api/products/images/${product._id}`); + if (response.data && response.data.image) { + setImageSrc(response.data.image); + setImageLoaded(true); + } + } catch (error) { + console.error('获取产品图片失败:', error); + } + }; + + fetchImage(); + }, [product._id]); + + return ( +
+ {/* 产品图片 */} +
+ {!imageLoaded ? ( + + ) : ( + {product.名称} + )} +
+ + {/* 产品信息 */} +
+
+ {product.名称} +
+ + ¥{(product as any).价格?.售价 || '未知'} + +
+
+ ); +}; + +/** + * 销售记录编辑模态框组件 + */ +const SalesModal: React.FC = ({ visible, onOk, onCancel, record }) => { + const [form] = Form.useForm(); + const [customers, setCustomers] = useState([]); + const [products, setProducts] = useState([]); + const [paymentPlatforms, setPaymentPlatforms] = useState([]); + const userInfo = useUserInfo(); // 获取当前用户信息 + const [users, setUsers] = useState([]); + + // 加载状态 + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + // 基于收款状态计算的额外字段显示控制 + const paymentStatus = Form.useWatch('收款状态', form); + + /** + * 模块级注释:数据初始化与表单设置 + * 当record或团队ID变化时加载相关数据并设置表单初始值 + */ + useEffect(() => { + if (visible && userInfo.团队?._id) { + setLoading(true); + const teamId = userInfo.团队._id; + + // 并行请求数据以提高加载速度 + Promise.all([ + fetchPaymentPlatforms(teamId), + fetchCustomers(teamId), + fetchProducts(teamId), + fetchUsers(teamId) + ]).finally(() => { + setLoading(false); + + // 如果有记录,设置表单值 + if (record) { + form.setFieldsValue({ + ...record, + 导购: record.导购?._id, + 成交日期: record.成交日期 ? dayjs(record.成交日期) : null, + 客户: record.客户?._id, + 产品: record.产品?.map(p => p._id) || [], + 收款平台: record.收款平台?._id, + }); + } else { + form.resetFields(); + // 设置默认值 + form.setFieldsValue({ + 成交日期: dayjs(), + 收款状态: '全款', + 待收款: 0, + 待收已收: 0 + }); + } + }); + } + }, [record, visible, userInfo.团队?._id]); + + // 监听应收金额和收款金额变化,自动计算待收款 + useEffect(() => { + + // 添加表单值变化监听 + const calculatePendingAmount = () => { + const receivableAmount = form.getFieldValue('应收金额') || 0; + const receivedAmount = form.getFieldValue('收款金额') || 0; + + // 只有当收款状态不是"全款"时才计算 + if (form.getFieldValue('收款状态') !== '全款') { + form.setFieldValue('待收款', receivableAmount - receivedAmount); + } else { + form.setFieldValue('待收款', 0); + } + }; + + form.getFieldInstance('应收金额')?.addEventListener('change', calculatePendingAmount); + form.getFieldInstance('收款金额')?.addEventListener('change', calculatePendingAmount); + + return () => { + form.getFieldInstance('应收金额')?.removeEventListener('change', calculatePendingAmount); + form.getFieldInstance('收款金额')?.removeEventListener('change', calculatePendingAmount); + }; + }, [form]); + + /** + * 模块级注释:数据获取函数 + */ + const fetchUsers = async (teamId: string) => { + try { + const { data } = await axios.get(`/api/backstage/users?teamId=${teamId}`); + setUsers(data.users); + return data.users; + } catch (error) { + message.error('加载用户数据失败'); + return []; + } + }; + + const fetchCustomers = async (teamId: string) => { + try { + const { data } = await axios.get(`/api/backstage/customers?teamId=${teamId}`); + setCustomers(data.customers); + return data.customers; + } catch (error) { + message.error('加载客户数据失败'); + return []; + } + }; + + const fetchProducts = async (teamId: string) => { + try { + const { data } = await axios.get(`/api/backstage/products?teamId=${teamId}`); + setProducts(data.products); + return data.products; + } catch (error) { + message.error('加载产品数据失败'); + return []; + } + }; + + const fetchPaymentPlatforms = async (teamId: string) => { + try { + const { data } = await axios.get(`/api/backstage/payment-platforms?teamId=${teamId}`); + setPaymentPlatforms(data.platforms); + return data.platforms; + } catch (error) { + message.error('加载收款平台数据失败'); + return []; + } + }; + + /** + * 模块级注释:表单提交处理 + */ + const handleOk = async () => { + try { + setSubmitting(true); + const values = await form.validateFields(); + + // 准备提交数据 + const updatedRecord = { + ...values, + 成交日期: values.成交日期 ? values.成交日期.toISOString() : null, + }; + + if (record?._id) { + // 更新已有记录 + await axios.put(`/api/backstage/sales/Records/${record._id}`, updatedRecord); + message.success('订单更新成功'); + } else { + // 创建新记录 (实际未实现) + message.error('系统不支持创建新记录,请联系管理员'); + return; + } + + onOk(); // 关闭模态框并刷新页面 + } catch (error) { + console.error('订单保存失败:', error); + message.error('订单保存失败,请检查表单数据'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + {record ? '编辑订单记录' : '添加订单记录'} + + } + onCancel={onCancel} + width={700} + styles={{ + body: { maxHeight: '75vh', overflow: 'auto', padding: '12px 24px' } + }} + footer={[ + , + , + ]} + maskClosable={false} + destroyOnClose={true} + > + +
+ {loading &&
加载数据中...
} +
+ {/* 基本信息区块 */} + 基本信息} + style={{ marginBottom: 16 }} + > + +
+ + + + + + + + + + + + + + + + + + + } + allowClear={false} + /> + + + + + + + + + + + {/* 财务信息区块 */} + 财务信息} + style={{ marginBottom: 16 }} + > + + + + } + step={10} + /> + + + + + + + + + + + + + + + + + + } + step={10} + /> + + + + + + + + + 待收款 + (应收-已收) + + + } + rules={[{ required: true, message: '请输入待收款金额' }]} + > + } + /> + + + + + + 待收已收 + (已追回) + + + } + > + } + /> + + + + + + + + + ); +}; + +export default SalesModal; diff --git a/src/pages/team/SaleRecord/ship-modal.tsx b/src/pages/team/SaleRecord/ship-modal.tsx new file mode 100644 index 0000000..2be892a --- /dev/null +++ b/src/pages/team/SaleRecord/ship-modal.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useState } from 'react'; +import { Modal, Form, Input, Button, message } from 'antd'; +import axios from 'axios'; +import { ISalesRecord } from '@/models/types'; +import { useUserInfo } from '@/store/userStore'; + +interface ShipModalProps { + visible: boolean; + onOk: () => void; + onCancel: () => void; + record: ISalesRecord | null; // 传入的销售记录 +} + +const ShipModal: React.FC = ({ visible, onOk, onCancel, record }) => { + const [form] = Form.useForm(); + const [logisticsNumbers, setLogisticsNumbers] = useState<{ [key: string]: string }>({}); // 保存每个产品的物流单号 + const userInfo = useUserInfo(); // 获取当前用户信息 + + useEffect(() => { + if (record) { + // 清空表单 + form.resetFields(); + form.setFieldsValue({ + 客户尾号: record?.客户?.电话 ? record.客户.电话.slice(-4) : '', // 自动填入客户电话尾号 + }); + + // 初始化物流单号状态 + const initialLogisticsNumbers: { [key: string]: string } = {}; + record?.产品?.forEach(product => { + initialLogisticsNumbers[product._id] = ''; // 初始化每个产品的物流单号为空 + }); + setLogisticsNumbers(initialLogisticsNumbers); + + // 获取已有的物流记录并填充单号 + axios + .get('/api/tools/logistics', { params: { 关联记录: record._id } }) + .then((res) => { + const logisticsRecords = res.data; + if (logisticsRecords && Array.isArray(logisticsRecords)) { + const updatedLogisticsNumbers: { [key: string]: string } = { ...initialLogisticsNumbers }; + + // 遍历物流记录,将已有的单号填充到对应的产品 + logisticsRecords.forEach((logisticsRecord: any) => { + const productId = logisticsRecord.产品?._id || logisticsRecord.产品; + if (productId && logisticsRecord.物流单号) { + updatedLogisticsNumbers[productId] = logisticsRecord.物流单号; + } + }); + + setLogisticsNumbers(updatedLogisticsNumbers); // 更新状态以填充表单 + } + }) + //查不到物流记录时,提示警告而不是报错,警告信息为“暂无物流记录” + .catch((err) => { + console.error('获取物流记录失败:', err); + message.error('暂无物流记录'); + }); + } + }, [record, form]); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + + // 过滤出有物流单号的产品 + const productsWithLogisticsNumbers = (record?.产品 || []) // 确保 record?.产品 始终是数组 + .map(product => ({ + productId: product._id, + logisticsNumber: logisticsNumbers[product._id] + })) + .filter(item => item.logisticsNumber); // 只保留填写了物流单号的产品 + + if (productsWithLogisticsNumbers.length === 0) { + message.error('请至少为一个产品填写物流单号'); + return; + } + + const logisticsData = { + ...values, + 团队: userInfo.团队?._id, + 关联记录: record?._id, + 类型: 'SalesRecord', // 确保类型为销售记录 + 产品: productsWithLogisticsNumbers // 只提交填写了物流单号的产品 + }; + + await axios.post('/api/tools/logistics', logisticsData); + message.success('发货信息提交成功'); + onOk(); // 关闭模态框 + } catch (error) { + message.error('发货信息提交失败'); + } + }; + + const handleLogisticsNumberChange = (productId: string, value: string) => { + setLogisticsNumbers(prevState => ({ + ...prevState, + [productId]: value // 更新每个产品的物流单号 + })); + }; + + return ( + + 取消 + , + , + ]} + > +
+ + + + + {/* 动态生成每个产品的物流单号输入框 */} + {record?.产品?.map(product => ( + + handleLogisticsNumberChange(product._id, e.target.value)} + onChange={e => { + const cleanedValue = e.target.value.replace(/[\s.,/#!$%\^&\*;:{}=\-_`~()<>[\]'"|\\?@+]/g, '');//过滤特殊字符和空格 + handleLogisticsNumberChange(product._id, cleanedValue); + }} + /> + + ))} + +
+ ); +}; + +export default ShipModal; diff --git a/src/pages/team/SaleRecord/test.tsx b/src/pages/team/SaleRecord/test.tsx new file mode 100644 index 0000000..200f872 --- /dev/null +++ b/src/pages/team/SaleRecord/test.tsx @@ -0,0 +1,1397 @@ +//src\pages\team\SaleRecord\index.tsx +import React, { useState, useEffect, useMemo, lazy, Suspense } from "react"; +import { + Table, + Button, + message, + TableColumnType, + Tag, + Tooltip, + Input, + Space, + Skeleton, + DatePicker, + Switch, + Radio, +} from "antd"; +import axios from "axios"; +import { ISalesRecord, ICoupon } from "@/models/types"; // 确保有正确的类型定义 +import { useUserInfo } from "@/store/userStore"; // 使用 Zustand 获取用户信息 +import { + FieldTimeOutlined, + MobileOutlined, + UserOutlined, + WechatOutlined, + SearchOutlined, + DownloadOutlined, +} from "@ant-design/icons"; +import { IconButton, Iconify } from "@/components/icon"; +import MyTooltip from "@/components/tooltip/MyTooltip"; +import ProductCardList from "@/components/product/ProductCardList"; + +// 导出Excel相关库 +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; + +// 使用 React.lazy 延迟加载这些组件 +const EditSalesModal = lazy(() => import("./sales-modal")); +const AfterSalesModal = lazy(() => import("./AfterSalesModal")); +const ShipModal = lazy(() => import("./ship-modal")); + +const SalesPage = () => { + // 添加CSS样式用于自定义滚动条 + useEffect(() => { + const style = document.createElement('style'); + style.textContent = ` + .product-scroll-container::-webkit-scrollbar { + height: 6px; + } + .product-scroll-container::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + .product-scroll-container::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 3px; + } + .product-scroll-container::-webkit-scrollbar-thumb:hover { + background: #bfbfbf; + } + + /* 确保Table筛选下拉框正确显示 */ + .ant-table-filter-dropdown { + z-index: 1050 !important; + position: fixed !important; + } + + .ant-dropdown { + z-index: 1050 !important; + } + + .ant-select-dropdown { + z-index: 1050 !important; + } + `; + document.head.appendChild(style); + + // 清理函数:组件卸载时移除样式 + return () => { + document.head.removeChild(style); + }; + }, []); + + const [isModalVisible, setIsModalVisible] = useState(false); // 编辑销售记录模态框 + const [isShipModalVisible, setIsShipModalVisible] = useState(false); // 新增发货模态框 + const [currentRecord, setCurrentRecord] = useState(null); // 当前选中的销售记录 + const [loading, setLoading] = useState(false); + const showModal = (record: ISalesRecord) => { + setCurrentRecord(record); + setIsModalVisible(true); + }; + const handleModalOk = () => { + setIsModalVisible(false); + if (userInfo.团队?._id) { + fetchSalesRecords(userInfo.团队._id); + } + }; + const showShipModal = (record: ISalesRecord) => { + setCurrentRecord(record); + setIsShipModalVisible(true); + }; + const handleShipModalOk = () => { + setIsShipModalVisible(false); + }; + const handleModalCancel = () => { + setIsModalVisible(false); + setIsShipModalVisible(false); // 关闭ShipModal + }; + const handleAfterSalesModalOk = () => { + setAfterSalesVisible(false); // 关闭模态框 + if (userInfo.团队?._id) { + fetchSalesRecords(userInfo.团队._id); + } + }; + const [salesRecords, setSalesRecords] = useState([]); + const [filteredRecords, setFilteredRecords] = useState([]); + const [transactionDateRange, setTransactionDateRange] = useState<[Date | null, Date | null]>([null, null]); + const [addFansDateRange, setAddFansDateRange] = useState<[Date | null, Date | null]>([null, null]); + const userInfo = useUserInfo(); // 获取当前用户信息 + + // 获取用户角色 + const userRole = userInfo?.角色?.名称; // 用户角色名称 + const isAdmin = userRole === "系统管理员" || userRole === "团队管理员"; // 是否为管理员角色 + const isNotFinanceRole = userRole !== "财务"; // 判断是否不是财务角色 + + const [dateSort, setDateSort] = useState<'成交日期' | '创建日期'>('成交日期'); // 新增状态:日期排序类型 + const [sortDirection, setSortDirection] = useState<'ascend' | 'descend'>('descend'); // 新增状态:排序方向 + + useEffect(() => { + if (userInfo.团队?._id) { + fetchSalesRecords(userInfo.团队._id); + } + }, [userInfo]); // 恢复原来的依赖项,只在userInfo变化时重新获取数据 + + useEffect(() => { + if (salesRecords.length > 0) { + // 对现有数据按新的排序条件重新排序 + const sortedRecords = [...salesRecords].sort((a, b) => { + const aDate = dateSort === '成交日期' + ? new Date(a.成交日期 || 0).getTime() + : new Date(a.createdAt || 0).getTime(); + const bDate = dateSort === '成交日期' + ? new Date(b.成交日期 || 0).getTime() + : new Date(b.createdAt || 0).getTime(); + + return sortDirection === 'ascend' ? aDate - bDate : bDate - aDate; + }); + + setSalesRecords(sortedRecords); + + // 应用现有筛选条件到排序后的记录 + let filtered = applyFilters(sortedRecords); + setFilteredRecords(filtered); + } + }, [dateSort, sortDirection]); + + // 当日期范围筛选条件改变时应用筛选 + useEffect(() => { + if (salesRecords.length > 0) { + const filtered = applyFilters(salesRecords); + setFilteredRecords(filtered); + } + }, [transactionDateRange, addFansDateRange]); + + // 筛选函数:应用日期范围筛选条件 + const applyFilters = (records: ISalesRecord[]) => { + let filtered = [...records]; + + // 根据成交日期范围筛选 + if (transactionDateRange[0] && transactionDateRange[1]) { + filtered = filtered.filter(record => { + if (!record.成交日期) return false; + const recordDate = new Date(record.成交日期); + return recordDate >= transactionDateRange[0]! && + recordDate <= transactionDateRange[1]!; + }); + } + + // 根据加粉日期范围筛选 + if (addFansDateRange[0] && addFansDateRange[1]) { + filtered = filtered.filter(record => { + if (!record.客户?.加粉日期) return false; + const addFansDate = new Date(record.客户.加粉日期); + return addFansDate >= addFansDateRange[0]! && + addFansDate <= addFansDateRange[1]!; + }); + } + + return filtered; + }; + + useEffect(() => { + if (salesRecords.length > 0) { + // 预加载产品图片和物流状态 + const productIds = new Set(); + const recordIds = new Set(); + + salesRecords.forEach(record => { + if (record._id) recordIds.add(record._id); + if (record.产品 && record.产品.length > 0) { + record.产品.forEach(product => { + if (product._id) productIds.add(product._id); + }); + } + }); + + // 批量预加载产品图片 + if (productIds.size > 0) { + axios.post('/api/products/batchImages', { + productIds: Array.from(productIds) + }).then(response => { + // 存储图片到本地缓存 + const imageData = response.data?.images || {}; + localStorage.setItem('productImageCache', JSON.stringify(imageData)); + console.log('预加载产品图片成功'); + }).catch(error => { + console.error('预加载产品图片失败', error); + }); + } + + // 批量预加载物流状态 + if (recordIds.size > 0) { + axios.post('/api/logistics/batchStatus', { + recordIds: Array.from(recordIds) + }).then(response => { + // 存储物流状态到本地缓存 + const statusData = response.data?.statuses || {}; + localStorage.setItem('logisticsStatusCache', JSON.stringify(statusData)); + console.log('预加载物流状态成功'); + }).catch(error => { + console.error('预加载物流状态失败', error); + }); + } + } + }, [salesRecords]); + + // 获取销售记录 + const fetchSalesRecords = async (teamId: string) => { + setLoading(true); // 请求开始前设置加载状态为 true + try { + const { data } = await axios.get( + `/api/backstage/sales/Records?teamId=${teamId}` + ); + const records = data.salesRecords || []; + + // 对获取的记录按当前排序设置进行排序 + const sortedRecords = [...records].sort((a, b) => { + const aDate = dateSort === '成交日期' + ? new Date(a.成交日期 || 0).getTime() + : new Date(a.createdAt || 0).getTime(); + const bDate = dateSort === '成交日期' + ? new Date(b.成交日期 || 0).getTime() + : new Date(b.createdAt || 0).getTime(); + + return sortDirection === 'ascend' ? aDate - bDate : bDate - aDate; + }); + + setSalesRecords(sortedRecords); // 设置已排序的销售记录 + setFilteredRecords(sortedRecords); // 初始化filteredRecords为已排序的记录 + setLoading(false); + } catch (error) { + message.error("加载销售记录失败"); // 处理错误 + } finally { + setLoading(false); // 无论成功还是失败都确保加载状态被设置为 false + } + }; + + // 提取唯一的导购姓名用于筛选 + // 提取唯一的成交日期用于筛选 + // 合并导购姓名和成交日期筛选 + const [afterSalesVisible, setAfterSalesVisible] = useState(false); // 售后模态框 + const [afterSalesType, setAfterSalesType] = useState< + "退货" | "换货" | "补发" | "补差" | null + >(null); + // 处理售后操作 + const handleAfterSales = ( + record: ISalesRecord, + type: "退货" | "换货" | "补发" | "补差" + ) => { + setCurrentRecord(record); + setAfterSalesType(type); + setAfterSalesVisible(true); + }; + //const columns: TableColumnType[] = useMemo(() => [ + const columns: TableColumnType[] = useMemo(() => { + const baseColumns: TableColumnType[] = [ + { + title: "来源", + width: 40, + key: "订单来源信息", + align: "center", + // 添加账号编号筛选功能 + filterDropdown: ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + }) => ( +
+ + setSelectedKeys(e.target.value ? [e.target.value] : []) + } + onPressEnter={() => confirm()} + style={{ marginBottom: 8, display: "block" }} + /> + + + + +
+ ), + filterIcon: (filtered) => ( + + ), + onFilter: (value, record) => { + const accountNumber = record.订单来源?.账号编号 ?? ""; + // 将 value 转换为字符串类型 + const searchVal = String(value); + return accountNumber.includes(searchVal); + }, + render: (record: any) => { + const wechatId = record.订单来源?.微信号 ?? "未知"; + const accountNumber = record.订单来源?.账号编号 ?? "未知"; + return ( +
+ } + color="blue" + style={{ marginBottom: 4 }} + > + {accountNumber} + + } color="green"> + {wechatId} + +
+ ); + }, + }, + + { + title: () => ( +
+ 日期/导购 + setDateSort(e.target.value)} + size="small" + buttonStyle="solid" + style={{ margin: '0 2px' }} + > + + 成交 + + + 创建 + + + 降} + unCheckedChildren={} + checked={sortDirection === 'descend'} + onChange={(checked) => setSortDirection(checked ? 'descend' : 'ascend')} + size="small" + style={{ + marginLeft: 2, + minWidth: '32px', + height: '20px' + }} + /> +
+ ), + key: "日期和导购信息", + align: "center", + width: 160, // 减小宽度因为我们优化了控件大小 + render: (record: any) => { + // 格式化日期为更简洁的形式 (年-月-日) + const formatDate = (dateString: string) => { + if (!dateString) return "未知"; + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).replace(/\//g, '-'); + }; + const transactionDate = formatDate(record.成交日期); + const createdDate = formatDate(record.createdAt); + const guideName = record.导购?.姓名 ?? "未知"; + + // 为完整信息创建tooltip内容 + return ( +
+ {/* 导购信息 */} + } + color="green" + style={{ + marginBottom: 4, + textAlign: 'center', + fontWeight: 'bold' + }} + > + {guideName} + + + {/* 成交日期 */} + } + color={dateSort === '成交日期' ? 'blue' : 'default'} + style={{ + margin: 0, + textAlign: 'center', + fontSize: '11px', + fontWeight: dateSort === '成交日期' ? 'bold' : 'normal', + padding: dateSort === '成交日期' ? '0 5px' : '0 4px' + }} + > + 成交: {transactionDate} + + + {/* 创建日期 */} + } + color={dateSort === '创建日期' ? 'blue' : 'default'} + style={{ + margin: 0, + textAlign: 'center', + fontSize: '11px', + fontWeight: dateSort === '创建日期' ? 'bold' : 'normal', + padding: dateSort === '创建日期' ? '0 5px' : '0 4px' + }} + > + 创建: {createdDate} + +
+ ); + }, + }, + { + title: "客户信息", + width: 60, + align: "center", + key: "客户信息", + // 增加filterDropdown用于尾号查询 + filterDropdown: ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + }) => ( +
+ + setSelectedKeys(e.target.value ? [e.target.value] : []) + } + onPressEnter={() => confirm()} + style={{ marginBottom: 8, display: "block" }} + /> + + + + +
+ ), + filterIcon: (filtered) => ( + + ), + onFilter: (value, record) => { + const phoneTail = record.客户?.电话 ? record.客户.电话.slice(-4) : ""; + // 将 value 转换为字符串类型 + const searchVal = String(value); + return phoneTail.includes(searchVal); + }, + render: (record: any) => { + // 拼接地址信息 + const address = record.客户?.地址 + ? `${record.客户.地址.省份 ?? ""} ${record.客户.地址.城市 ?? ""} ${record.客户.地址.区县 ?? "" + } ${record.客户.地址.详细地址 ?? ""}` + : "未知"; + + const customerName = record.客户?.姓名 ?? "未知"; + + // 计算成交周期 + const transactionDate = record.成交日期 ? new Date(record.成交日期) : new Date(); + const addFansDate = record.客户?.加粉日期 ? new Date(record.客户.加粉日期) : null; + const diffDays = addFansDate ? Math.ceil( + Math.abs(transactionDate.getTime() - addFansDate.getTime()) / (1000 * 60 * 60 * 24) + ) : '未知'; + + // 从 record 获取待收金额 + const unreceivedAmount = record.待收款 + ? parseFloat(record.待收款.toFixed(2)) + : 0; + // 如果待收金额大于0,使用红色,否则使用蓝色 + const customerTagColor = unreceivedAmount > 0 ? "#cd201f" : "blue"; + + return ( +
+ {/* 左侧复制按钮 */} +
+ {isAdmin && ( + { + try { + if ( + typeof window !== "undefined" && + window.navigator.clipboard && + window.navigator.clipboard.writeText + ) { + // 将客户信息转换为纯文本 + const customerText = `姓名:${record.客户?.姓名 ?? "未知" + }\n电话:${record.客户?.电话 ?? "未知" + }\n地址:${address}`; + await window.navigator.clipboard.writeText( + customerText + ); + message.success(`客户 ${customerName} 信息复制成功!`); + } else { + message.error("当前浏览器不支持复制功能"); + } + } catch (error) { + console.error("客户信息复制失败:", error); + message.error("客户信息复制失败"); + } + }} + > + + + )} +
+ + {/* 右侧标签信息,每行一个标签 */} + +
+ {/* 第一行:客户姓名 */} + } + color={customerTagColor} + style={{ margin: 0 }} + > + {record.客户?.姓名 ?? "未知"} + + + {/* 第二行:电话尾号 */} + } color="green" style={{ margin: 0 }}> + {record.客户?.电话 + ? `${record.客户.电话.slice(-4)}` + : "未知"} + + + {/* 第三行:加粉日期 */} + } + color="purple" + style={{ margin: 0 }} + > + {record.客户?.加粉日期 + ? new Date(record.客户.加粉日期).toLocaleDateString() + : "未知"} + + + {/* 第四行:成交周期 */} + + 成交周期:{diffDays}天 + +
+
+
+ ); + }, + }, + //备注 + { + title: "备注", + key: "备注", + dataIndex: "备注", + width: 260, + render: (text: string, record: any) => { + // 获取产品列表 + const products = record.产品 || []; + const fetchBase64ImageAsBlob = async ( + productId: string + ): Promise => { + const response = await fetch(`/api/products/images/${productId}`); + const data = await response.json(); + + if (!data || !data.image) { + throw new Error(`未找到有效的 image 数据,产品ID: ${productId}`); + } + + const base64Data = data.image; + if (!base64Data.includes(",")) { + throw new Error(`无效的 Base64 数据,产品ID: ${productId}`); + } + + const byteCharacters = atob(base64Data.split(",")[1]); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray], { type: "image/png" }); + }; + + const handleCopyAll = async () => { + try { + const customerName = record.客户?.姓名 ?? "未知"; + const tail = record.客户?.电话 + ? record.客户.电话.slice(-4) + : "****"; + const remark = text || "无备注"; + + // 生成所有产品的文本内容 + let productText = ""; + if (products.length > 0) { + products.forEach((product: any, index: number) => { + productText += `【产品${index + 1}】${product.名称 || "未知产品"}\n`; + }); + } else { + productText = "【产品】无产品\n"; + } + + // 改为多行格式,包含所有产品 + const combinedText = `【客户】${customerName}-${tail}\n${productText}【备注】${remark}`; + + if (products.length > 0) { + try { + // 仅复制第一款产品的图片,避免复制太多内容导致剪贴板问题 + const productId = products[0]._id; + const blob = await fetchBase64ImageAsBlob(productId); + + const clipboardItems: Record = { + "text/plain": new Blob([combinedText], { + type: "text/plain", + }), + }; + clipboardItems[blob.type] = blob; + + const clipboardItem = new ClipboardItem(clipboardItems); + await navigator.clipboard.write([clipboardItem]); + message.success("客户信息、所有产品名称、备注和图片已复制"); + } catch (imageError) { + // 如果图片复制失败,至少保证文本信息被复制 + console.error("图片复制失败,仅复制文本信息:", imageError); + await navigator.clipboard.writeText(combinedText); + message.success("客户信息、所有产品名称和备注已复制"); + } + } else { + // 没有产品时只复制文本 + await navigator.clipboard.writeText(combinedText); + message.success("客户信息、备注已复制"); + } + } catch (err) { + console.error("复制失败:", err); + message.error("复制信息失败"); + } + }; + + return ( +
+ {/* 按钮区:上下排列 */} +
+ {/* 一键复制按钮(活力橙色) */} + + + +
+ + {/* 备注内容 */} + +
+ {text || "无备注"} +
+
+
+ ); + }, + }, + //使用替换,显示产品信息 + { + title: "产品信息", + width: 420, // 增加宽度以适应3个产品 (130px * 3 + 间距) + dataIndex: "产品", + key: "productImage", + render: (products: any[], record: ISalesRecord) => { + return ( +
+ +
+ ); + }, + }, + { + title: "财务信息", + key: "financialInfo", + width: 180, // 适当调整宽度 + render: (record: any) => { + const paymentPlatform = record.收款平台?.名称 ?? "未知"; + const receivedAmount = record.收款金额 + ? record.收款金额.toFixed(2) + : "0.00"; + const paymentStatus = record.收款状态 ?? "未知"; + const receivableAmount = record.应收金额 + ? record.应收金额.toFixed(2) + : "0.00"; + const unreceivedAmount = record.待收款 + ? record.待收款.toFixed(2) + : "0.00"; + const receivedPendingAmount = record.待收已收 + ? record.待收已收.toFixed(2) + : "0.00"; // 待收已收字段 + + return ( +
+
+ {paymentPlatform} + + 应收: ¥{receivableAmount} + +
+
+ {paymentStatus} + + 收款: ¥{receivedAmount} + +
+
+ 余额抵用: + + ¥{record.余额抵用?.金额 ?? 0} + +
+
+ 优惠券: +
+ {record.优惠券?.map((coupon: ICoupon) => ( + + 类型:{coupon._id.优惠券类型} +
+ 金额:{coupon._id.金额} +
+ 折扣:{coupon._id.折扣} + + } + > + + {coupon._id.优惠券类型} + +
+ ))} +
+
+ +
+ + 0 + ? "#ff4d4f" + : "inherit", // 只有大于0时才显示红色 + }} + > + 待收:¥{unreceivedAmount} + + +
+ 已收: + + ¥{receivedPendingAmount} + +
+
+
+ ); + }, + }, + ]; + + // 如果用户不是财务角色,添加操作列 + if (isNotFinanceRole) { + baseColumns.push({ + title: "操作", + key: "action", + align: "center", + fixed: "right", + width: 160, + render: (_: any, record: ISalesRecord) => ( +
+
+ + + + + + + + + + + + +
+
+ + +
+
+ ), + }); + } + + return baseColumns; + }, [isNotFinanceRole, isAdmin, userInfo, dateSort, sortDirection]); + + // 导出Excel的函数 + const exportToExcel = () => { + if (filteredRecords.length === 0) { + message.warning('没有可导出的数据'); + return; + } + + message.loading({ content: '正在准备导出数据...', key: 'exporting', duration: 0 }); + + try { + // 准备导出数据 + const exportData = filteredRecords.map((record, index) => { + // 处理产品信息 - 合并成一个字符串 + const productsText = record.产品?.map(product => + `${product.名称 || '未知'}` + ).join(', ') || '无产品'; + + // 计算成交周期 + const transactionDate = record.成交日期 ? new Date(record.成交日期) : new Date(); + const addFansDate = record.客户?.加粉日期 ? new Date(record.客户.加粉日期) : null; + const diffDays = addFansDate ? Math.ceil( + Math.abs(transactionDate.getTime() - addFansDate.getTime()) / (1000 * 60 * 60 * 24) + ) : '未知'; + + // 格式化日期 + const formatDate = (dateStr: string | undefined | Date): string => { + if (!dateStr) return '未知'; + try { + const date = dateStr instanceof Date ? dateStr : new Date(dateStr); + if (isNaN(date.getTime())) return '无效日期'; + return date.toLocaleDateString('zh-CN'); + } catch { + return '无效日期'; + } + }; + + // 处理电话号码 - 只保留尾号 + const phoneNumber = record.客户?.电话 + ? `${record.客户.电话.slice(-4)}` + : '未知'; + + // 计算售后产品金额 + let afterSalesAmount = 0; + if (record.售后记录 && record.售后记录.length > 0 && record.产品 && record.产品.length > 0) { + const afterSalesProductIds = new Set( + record.售后记录.flatMap(afterSales => + afterSales.原产品.map(productId => String(productId)) + ) + ); + + record.产品.forEach(product => { + if (afterSalesProductIds.has(String(product._id))) { + afterSalesAmount += (product.售价 || 0); + } + }); + } + + // 计算产品总成本(成本价+包装费+运费) + let totalProductCost = 0; + if (record.产品 && record.产品.length > 0) { + totalProductCost = record.产品.reduce((sum, product) => { + const costPrice = product.成本?.成本价 || 0; + const packagingFee = product.成本?.包装费 || 0; + const shippingFee = product.成本?.运费 || 0; + return sum + costPrice + packagingFee + shippingFee; + }, 0); + } + + // 返回导出的行数据 + return { + '序号': index + 1, + '客户姓名': record.客户?.姓名 || '未知', + '电话尾号': phoneNumber, + '成交日期': formatDate(record.成交日期), + '加粉日期': formatDate(record.客户?.加粉日期), + '成交周期(天)': diffDays, + '导购': record.导购?.姓名 || '未知', + '账号编号': record.订单来源?.账号编号 || '未知', + '产品信息': productsText, + '应收金额': record.应收金额 || 0, + '收款金额': record.收款金额 || 0, + '待收金额': record.待收款 || 0, + '待收已收': record.待收已收 || 0, + '收款平台': record.收款平台?.名称 || '未知', + '收款状态': record.收款状态 || '未知', + '总成本': totalProductCost, + '售后金额': afterSalesAmount, + '备注': record.备注 || '无', + '创建时间': record.createdAt ? formatDate(new Date(record.createdAt)) : '未知', + }; + }); + + // 创建工作簿和工作表 + const ws = XLSX.utils.json_to_sheet(exportData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, '销售记录'); + + // 设置列宽 (大致估计,可能需要调整) + const colWidths = [ + { wch: 5 }, // 序号 + { wch: 10 }, // 客户姓名 + { wch: 10 }, // 电话尾号 + { wch: 10 }, // 成交日期 + { wch: 10 }, // 加粉日期 + { wch: 8 }, // 成交周期 + { wch: 10 }, // 导购 + { wch: 10 }, // 账号编号 + { wch: 40 }, // 产品信息 + { wch: 10 }, // 应收金额 + { wch: 10 }, // 收款金额 + { wch: 10 }, // 待收金额 + { wch: 10 }, // 待收已收 + { wch: 10 }, // 收款平台 + { wch: 10 }, // 收款状态 + { wch: 10 }, // 总成本 + { wch: 10 }, // 售后金额 + { wch: 20 }, // 备注 + { wch: 20 }, // 创建时间 + ]; + ws['!cols'] = colWidths; + + // 生成Excel文件并下载 + const now = new Date(); + const dateStr = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}`; + const fileName = `销售记录导出_${dateStr}.xlsx`; + + // 使用file-saver保存文件 + const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([wbout], { type: 'application/octet-stream' }); + saveAs(blob, fileName); + + message.success({ content: '导出成功!', key: 'exporting' }); + } catch (error) { + console.error('导出Excel失败:', error); + message.error({ content: '导出失败,请重试', key: 'exporting' }); + } + }; + + return ( +
+
{ + // 计算统计数据 + const calculateStats = () => { + // 总记录数量 + const recordCount = filteredRecords.length; + + // 总出单数量(所有产品数量的总和) + const orderCount = filteredRecords.reduce((sum, record) => { + // 如果产品数组存在且有长度,就累加产品数量 + // 如果产品数组不存在或为空,就按1计算 + if (record.产品 && record.产品.length > 0) { + return sum + record.产品.length; + } + return sum + 1; // 如果没有产品信息,按一单计算 + }, 0); + + // 计算不重复的客户数量(根据客户电话号码去重) + const uniqueCustomers = new Set(); + filteredRecords.forEach(record => { + if (record.客户?.电话) { + uniqueCustomers.add(record.客户.电话); + } + }); + const customerCount = uniqueCustomers.size; + + // 计算总销售额(应收金额) + const totalSales = filteredRecords.reduce((sum, record) => { + return sum + (record.应收金额 || 0); + }, 0); + + // 计算总成本 + const totalCost = filteredRecords.reduce((sum, record) => { + if (record.产品 && record.产品.length > 0) { + // 累加每个产品的总成本(成本价+包装费+运费) + const productsCost = record.产品.reduce((costSum, product) => { + // 计算单个产品的总成本 = 成本价 + 包装费 + 运费 + const costPrice = product.成本?.成本价 || 0; + const packagingFee = product.成本?.包装费 || 0; + const shippingFee = product.成本?.运费 || 0; + const totalProductCost = costPrice + packagingFee + shippingFee; + + // 假设每个产品数量为1,如果有产品数量字段,应该乘以数量 + return costSum + totalProductCost; + }, 0); + return sum + productsCost; + } + return sum; + }, 0); + + // 计算毛利率 = (销售额-成本)/销售额 * 100% + const profitRate = totalSales > 0 + ? ((totalSales - totalCost) / totalSales * 100).toFixed(1) + : '0.0'; + + // 计算客单价 = 总销售额/客户数量 + const customerPrice = customerCount > 0 + ? (totalSales / customerCount).toFixed(2) + : '0.00'; + + // 计算件单价 = 总销售额/总出单数量 + const unitPrice = orderCount > 0 + ? (totalSales / orderCount).toFixed(2) + : '0.00'; + + // 计算售后产品金额 + let afterSalesAmount = 0; + filteredRecords.forEach(record => { + if (record.售后记录 && record.售后记录.length > 0 && record.产品 && record.产品.length > 0) { + const afterSalesProductIds = new Set( + record.售后记录.flatMap(afterSales => + afterSales.原产品.map(productId => String(productId)) + ) + ); + + record.产品.forEach(product => { + if (afterSalesProductIds.has(String(product._id))) { + afterSalesAmount += (product.售价 || 0); + } + }); + } + }); + + return { + orderCount, + customerCount, + totalSales, + totalCost, + profitRate, + recordCount, + customerPrice, + unitPrice, + afterSalesAmount + }; + }; + + // 获取统计数据 + const stats = calculateStats(); + + // 渲染筛选状态标签 + const renderFilterStatus = () => { + if (transactionDateRange[0] || addFansDateRange[0]) { + return ( + + + 共: {stats.recordCount} / {salesRecords.length} 条记录 + + + + ); + } + return null; + }; + + return ( +
+
+
+ 成交日期: + { + if (dateStrings[0] && dateStrings[1]) { + // 设置开始日期为当天的00:00:00 + const startDate = new Date(dateStrings[0]); + startDate.setHours(0, 0, 0, 0); + + // 设置结束日期为当天的23:59:59,确保包含当天的所有记录 + const endDate = new Date(dateStrings[1]); + endDate.setHours(23, 59, 59, 999); + + setTransactionDateRange([startDate, endDate]); + } else { + setTransactionDateRange([null, null]); + } + }} + /> +
+
+ 加粉日期: + { + if (dateStrings[0] && dateStrings[1]) { + // 设置开始日期为当天的00:00:00 + const startDate = new Date(dateStrings[0]); + startDate.setHours(0, 0, 0, 0); + + // 设置结束日期为当天的23:59:59,确保包含当天的所有记录 + const endDate = new Date(dateStrings[1]); + endDate.setHours(23, 59, 59, 999); + + setAddFansDateRange([startDate, endDate]); + } else { + setAddFansDateRange([null, null]); + } + }} + /> +
+ + {renderFilterStatus()} + + {/* 添加统计信息 */} +
+ + }> + 客户: {stats.customerCount} + + + + }> + 记录/出单: {stats.recordCount}/{stats.orderCount} + + + + + 销售额: ¥{stats.totalSales.toFixed(2)} + + + + + 成本: ¥{stats.totalCost.toFixed(2)} + + + + + 毛利: ¥{(stats.totalSales - stats.totalCost).toFixed(2)} + + + + + 毛利率: {stats.profitRate}% + + + + }> + 客单价: ¥{stats.customerPrice} + + + + }> + 件单价: ¥{stats.unitPrice} + + + + + 售后金额: ¥{stats.afterSalesAmount.toFixed(2)} + + + + {/* 添加导出按钮 */} + +
+
+
+ ); + }} + scroll={{ + y: `calc(100vh - 250px)`, // 适当调整高度 + x: 'max-content' + }} + pagination={{ + position: ['bottomRight'], + pageSize: 100, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100"], + showTotal: (total) => `共 ${total} 条记录`, + size: 'small' + }} + size="small" + loading={loading} + columns={columns} + dataSource={filteredRecords} // 使用筛选后的记录 + rowKey="_id" + // 添加行渲染优化 + rowClassName={(_, index) => (index % 2 === 0 ? 'even-row' : 'odd-row')} + /> + }> + {isModalVisible && ( + setIsModalVisible(false)} + record={currentRecord} + /> + )} + {afterSalesVisible && ( + setAfterSalesVisible(false)} + record={currentRecord} + type={afterSalesType!} + /> + )} + {isShipModalVisible && ( + + )} + + + ); +}; + +export default SalesPage; diff --git a/src/styles/globals.css b/src/styles/globals.css index 5cdab48..244657f 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -18,14 +18,7 @@ /* ==================== 防闪烁优化 + 分层过渡策略 ==================== */ /* 关键代码行注释:防止SSR水合过程中的主题闪烁,采用分层过渡策略 */ -html { - background-color: #f6f9fc; - transition: background-color 0.4s ease; -} - -html.dark { - background-color: #0a1128; -} +/* 注意:主要的背景设置已移动到下方的html选择器中,这里仅保留基础设置 */ /* ==================== 全局变量系统 ==================== */ /* 关键代码行注释:统一在一个:root中定义所有全局变量,提高可维护性 */ @@ -154,7 +147,17 @@ body { transition: var(--transition-slow); } -/* 关键代码行注释:正确的背景应用方式,将背景设置在body元素上 */ +/* 关键代码行注释:将背景设置在html元素上,确保背景完全固定不随滚动 */ +html { + /* 关键代码行注释:使用变量方式应用背景,确保背景图片能正确显示 */ + background-color: var(--bg-primary); + background-image: var(--bg-gradient); + background-attachment: fixed; + background-size: 100% 100%; + background-repeat: no-repeat; + transition: var(--transition-slow); +} + body { min-height: 100vh; line-height: 1.6; @@ -162,13 +165,10 @@ body { font-weight: 400; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - /* 关键代码行注释:使用变量方式应用背景,确保背景图片能正确显示 */ + /* 关键代码行注释:body元素只设置文字颜色,不设置背景 */ color: var(--text-primary); - background-color: var(--bg-primary); - background-image: var(--bg-gradient); - background-attachment: fixed; - background-size: 100% 100%; - background-repeat: no-repeat; + /* 关键代码行注释:移除body的背景设置,让html的背景透过 */ + background: transparent; } a { diff --git a/src/utils/getAccessToken.ts b/src/utils/getAccessToken.ts new file mode 100644 index 0000000..850b8cd --- /dev/null +++ b/src/utils/getAccessToken.ts @@ -0,0 +1,35 @@ +// src\utils\getAccessToken.ts +import querystring from 'querystring'; + +export async function getAccessToken() { + // 从环境变量获取 API URL、partnerID 和 secret + const url = process.env.NEXT_PUBLIC_API_URL_OAUTH || 'https://sfapi.sf-express.com/oauth2/accessToken'; + const partnerID = process.env.PARTNER_ID || 'defaultPartnerID'; + const secret = process.env.SECRET || 'defaultSecret'; + + const data = querystring.stringify({ + partnerID, + secret, + grantType: 'password' + }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: data + }); + + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status}`); + } + + const responseData = await response.json(); + // 在 getAccessToken 中 + //console.log("Returning accessToken:", responseData.accessToken); + return responseData.accessToken; + } catch (error) { + console.error('Error getting access token:', error); + throw error; // 抛出错误让调用者处理 + } +} diff --git a/src/utils/querySFExpress.ts b/src/utils/querySFExpress.ts new file mode 100644 index 0000000..9b2b9a5 --- /dev/null +++ b/src/utils/querySFExpress.ts @@ -0,0 +1,48 @@ +// src\utils\querySFExpress.ts +import querystring from 'querystring'; +import { getAccessToken } from './getAccessToken'; // 确保路径正确 + +export async function querySFExpress(trackingNumber: string, phoneLast4Digits: string) { + // 在 querySFExpress 中 + const accessToken = await getAccessToken(); + //console.log("Received accessToken in querySFExpress:", accessToken); + const partnerID = 'YPD607MO'; + const reqUrl = 'https://bspgw.sf-express.com/std/service'; + const requestID = crypto.randomUUID(); + const serviceCode = "EXP_RECE_SEARCH_ROUTES"; + const timestamp = Date.now().toString(); + const msgData = { + language: "zh-CN", + trackingType: "1", + trackingNumber: [trackingNumber], + methodType: "1", + checkPhoneNo: phoneLast4Digits + }; + //console.log(`Querying SF Express with: accessToken=${accessToken}, trackingNumber=${trackingNumber}, phoneLast4Digits=${phoneLast4Digits}`); + const data = querystring.stringify({ + partnerID, + requestID, + serviceCode, + timestamp, + accessToken, + msgData: JSON.stringify(msgData) + }); + + try { + const response = await fetch(reqUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: data + }); + + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status}`); + } + + const responseData = await response.json(); + return responseData; + } catch (error) { + console.error('Error querying SF Express:', error); + throw error; + } +}