0606.14
Some checks failed
Next.js CI/CD 流水线 / deploy (push) Failing after 41s

This commit is contained in:
2025-06-06 21:02:14 +08:00
parent 8e0ae881dd
commit d098d58018
41 changed files with 6899 additions and 25 deletions

View File

@@ -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 (
<ConfigProvider
theme={yourTheme}
locale={zhCN}
>
<App>
<div className="app-container">
{children}
</div>
</App>
</ConfigProvider>
);
}
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<AppConfigProvider>
<Component {...pageProps} />
</AppConfigProvider>
);
}
```
### 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 (
<Button onClick={handleClick}>
Message
</Button>
);
};
export default MyComponent;
```
**错误方式:**
```tsx
import { message } from 'antd'; // ❌ 不推荐:静态导入
const MyComponent = () => {
const handleClick = () => {
message.success('操作成功!'); // ❌ 会产生警告
};
return <Button onClick={handleClick}></Button>;
};
```
## 📋 复制功能使用指南
### 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 (
<Paragraph
copyable={{
text: textToCopy,
onCopy: () => message.success('复制成功!'),
tooltips: ['点击复制', '复制成功']
}}
>
{textToCopy}
</Paragraph>
);
};
```
### 2. 自定义复制按钮
```tsx
import { Typography, Button } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
const CustomCopyButton = () => {
const { message } = useApp();
const copyText = "自定义复制内容";
return (
<Paragraph
copyable={{
text: copyText,
icon: <CopyOutlined />, // 自定义图标
onCopy: () => message.success('内容已复制到剪贴板'),
tooltips: ['复制内容', '复制成功']
}}
style={{ margin: 0 }}
>
{/* 空内容,只显示复制按钮 */}
</Paragraph>
);
};
```
### 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 (
<Tooltip title="复制内容">
<Button
icon={<CopyOutlined />}
onClick={handleComplexCopy}
>
</Button>
</Tooltip>
);
};
```
## 🎯 最佳实践
### 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 (
<div>
<Button onClick={showSuccess}></Button>
<Button onClick={showLoading}></Button>
</div>
);
};
```
### 2. 复制功能建议
```tsx
const CopyBestPractice = () => {
const { message } = useApp();
// ✅ 推荐:简单文本使用 Paragraph copyable
const simpleCopy = (
<Paragraph
copyable={{
text: "简单文本",
onCopy: () => message.success('复制成功'),
}}
>
</Paragraph>
);
// ✅ 推荐:复杂逻辑使用自定义函数 + Tooltip
const complexCopy = (
<Tooltip title="复制详细信息">
<Button onClick={handleComplexCopyLogic}>
</Button>
</Tooltip>
);
return (
<div>
{simpleCopy}
{complexCopy}
</div>
);
};
```
## ⚠️ 常见错误
### 1. 避免事件冲突
```tsx
// ❌ 错误:在 Paragraph copyable 中嵌套按钮会导致事件冲突
<Paragraph copyable={{ text: "内容" }}>
<Button></Button> {/* 会导致点击事件冲突 */}
</Paragraph>
// ✅ 正确:分别处理
<div style={{ display: 'flex', gap: 8 }}>
<Paragraph copyable={{ text: "内容" }}></Paragraph>
<Button></Button>
</div>
```
### 2. 避免样式冲突
```tsx
// ❌ 错误fontSize: 0 可能影响可点击区域
<Paragraph
copyable={{ text: "内容" }}
style={{ fontSize: 0 }} // 可能导致无法点击
>
</Paragraph>
// ✅ 正确:使用合适的样式
<Paragraph
copyable={{ text: "内容" }}
style={{ margin: 0, lineHeight: 1 }}
>
</Paragraph>
```
## 🔧 调试技巧
### 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): 初始版本,包含基础使用方法和最佳实践
---
> 💡 **提示**:遵循本文档的最佳实践,可以避免常见的警告和错误,提供更好的用户体验。

View File

@@ -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",

11
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -312,7 +312,18 @@ const Layout: React.FC<LayoutProps> = ({ 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<LayoutProps> = ({ children }) => {
locale="zh-CN"
>
<PageContainer
// 移除默认的页面标题栏
header={{ title: null }}
// 完全禁用页面头部渲染 - 确保没有额外的头部空间占用
pageHeaderRender={false}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
// 移除 PageContainer 自身的内边距
padding: 0,
margin: 0,
}}
// PageContainer 级别的内边距控制 - 双重保险移除所有内边距
token={{
// 移除内容区域的上下内边距 (垂直方向)
paddingBlockPageContainerContent: 0,
// 移除内容区域的左右内边距 (水平方向)
// 与 ProLayout 的 token 配置配合,确保完全移除左右空白
paddingInlinePageContainerContent: 0,
}}
>
{/* 最内层容器 - 实际承载页面内容的容器 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: '16px',
// 最终的边距控制 - 设置为 0 完全移除所有内边距
// 这是移除左右空白的最后一道防线
// 可以根据需要调整:如 '16px' 恢复默认边距,'0 16px' 只保留左右边距等
padding: '0px',
margin: 0,
overflow: 'auto',
boxSizing: 'border-box',

View File

@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Spin, Alert } from 'antd';
interface LogisticsDetail {
_id: string;
物流单号: string;
是否查询: boolean;
客户尾号: string;
更新时间: string;
关联记录: string;
类型: string;
createdAt: string;
updatedAt: string;
__v: number;
物流详情: string;
}
interface LogisticsQueryProps {
id: string;
}
const LogisticsQuery: React.FC<LogisticsQueryProps> = ({ id }) => {
const [data, setData] = useState<LogisticsDetail | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 (
<Spin size="large">
<div style={{ padding: '50px', textAlign: 'center' }}>Loading...</div>
</Spin>
);
}
if (error) {
return <Alert message="Error" description={error} type="error" showIcon />;
}
if (!data) {
return null;
}
return (
<div>
<h3>: {data.}</h3>
<p>: {new Date(data.).toLocaleString()}</p>
<h4>:</h4>
<pre>{data.}</pre>
</div>
);
};
export default LogisticsQuery;

View File

@@ -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<string, { status: string, timestamp: number }> = {};
const detailsCache: Record<string, { details: string, number: string, timestamp: number }> = {};
// 缓存过期时间(毫秒)
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<LogisticsStatusProps> = React.memo(({ recordId, productId }) => {
const [logisticsStatus, setLogisticsStatus] = useState<string | null>(null);
const [logisticsDetails, setLogisticsDetails] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [detailsLoading, setDetailsLoading] = useState<boolean>(false);
const [logisticsNumber, setLogisticsNumber] = useState<string | null>(null);
// 使用 ref 来跟踪当前组件是否已挂载,避免在组件卸载后设置状态
const isMountedRef = useRef(true);
// 使用 ref 来存储当前进行中的请求,用于取消
const currentRequestRef = useRef<AbortController | null>(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 <Spin size="small" />;
}
if (!logisticsStatus) {
return null; // 返回null而不是div减少不必要的DOM元素
}
return (
<MyTooltip
color="white"
title={
detailsLoading ? (
<Spin size="small" />
) : (
<div>
<span style={{ fontSize: 'larger' }}>{logisticsNumber}</span>
<p>{logisticsDetails || '暂无物流详情'}</p>
</div>
)
}
onMouseEnter={fetchLogisticsDetails}
>
<Tag
style={{
fontSize: '10px',
}}
bordered={false}
color={statusColorMapping[logisticsStatus] || 'default'}
>
{logisticsStatus}
</Tag>
</MyTooltip>
);
});
// 设置组件显示名称,便于调试
LogisticsStatus.displayName = 'LogisticsStatus';
// 使用React.memo避免不必要的重新渲染
export default LogisticsStatus;

View File

@@ -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<ProductCardListProps> = ({ products, record }) => {
// 新增状态管理
const [productModalVisible, setProductModalVisible] = useState(false);
const [editingProduct, setEditingProduct] = useState<IProduct | null>(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<Blob> => {
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 <div></div>;
}
return (
<div
style={{
display: 'flex',
gap: '8px',
padding: '4px 0',
}}
>
{products.map(product => {
const isAfterSales = afterSalesProductIds.has(String(product._id));
const supplierInfo = product.?.
? <>
: {product...}<br />
: {product...}<br />
: {product...}
</>
: '无供应商信息';
return (
<div
key={product._id}
style={{
flex: '0 0 auto',
position: 'relative',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
}}
>
{/* 产品图片 */}
<div style={{
borderRadius: '8px',
overflow: 'hidden',
width: '130px',
height: '130px',
}}>
<ProductImage
productId={product._id}
style={{
width: '100%',
height: '100%',
}}
/>
</div>
{/* 编辑和复制按钮 */}
{isAdmin && (
<div style={{ position: 'absolute', top: '6px', right: '6px', display: 'flex', gap: '4px' }}>
<IconButton
style={{ color: '#1890ff', padding: 4 }}
onClick={() => handleEdit(product)}
>
<Iconify icon="eva:edit-fill" size={16} />
</IconButton>
<IconButton
style={{ color: '#1890ff', padding: 4 }}
onClick={() => handleCopy(product._id)}
>
<Iconify icon="eva:copy-fill" size={16} />
</IconButton>
</div>
)}
{/* 供应商名称 */}
<div
style={{
position: 'absolute',
top: '8px',
left: '8px',
color: '#fff',
fontSize: '12px',
textShadow: '0 0 3px rgba(0,0,0,0.5)',
fontWeight: 'bold',
}}
>
<MyTooltip color="white" title={supplierInfo} placement="top">
<div>{product.?.}</div>
</MyTooltip>
</div>
{/* 产品名称 */}
<MyTooltip color="white" title={product.} placement="top">
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: isAfterSales ? 'red' : '#fff',
fontSize: '18px',
fontWeight: 'bold',
textShadow: '0 0 3px rgba(0,0,0,0.5)',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '90%',
//opacity: 0.9,
}}
>
{product.}
</div>
</MyTooltip>
{/* 成本价,显示在价格上方 */}
<div
style={{
position: 'absolute',
bottom: '26px',
left: '4px',
color: (!product.?. || product.?. === 0) ? '#ff4d4f' : '#fff',
fontSize: '12px',
fontWeight: 'bold',
textShadow: '0 0 3px rgba(0,0,0,0.5)',
}}
>
{(() => {
// 计算总成本 = 成本价 + 包装费 + 运费
const costPrice = product.?. || 0;
const packagingFee = product.?. || 0;
const shippingFee = product.?. || 0;
const totalCost = costPrice + packagingFee + shippingFee;
// 创建成本明细信息
const costDetails = (
<>
: {costPrice}<br />
: {packagingFee}<br />
: {shippingFee}<br />
<strong>: {totalCost}</strong>
</>
);
return totalCost === 0 ? (
'¥无成本'
) : (
<MyTooltip color="white" title={costDetails} placement="top">
<span>{totalCost.toFixed(2)}</span>
</MyTooltip>
);
})()}
</div>
{/* 价格 */}
<div
style={{
position: 'absolute',
bottom: '4px',
left: '4px',
color: '#fff',
fontSize: '16px',
fontWeight: 'bold',
textShadow: '0 0 3px rgba(0,0,0,0.5)',
}}
>
{product.}
</div>
{/* 物流状态 */}
<div
style={{
position: 'absolute',
bottom: '8px',
right: '0px',
color: '#1890ff',
}}
>
{isAfterSales && (
<div>
<Tag
color="red"
style={{
fontSize: '10px',
marginRight: 0,
marginBottom: '2px',
textAlign: 'center'
}}
>
</Tag>
</div>
)}
<LogisticsStatus recordId={record._id} productId={product._id} />
</div>
</div>
);
})}
{/* 编辑产品弹窗 */}
{productModalVisible && (
<ProductModal
visible={productModalVisible}
onOk={handleProductModalOk}
onCancel={handleProductModalCancel}
product={editingProduct}
/>
)}
</div>
);
};
// 移除React.memo避免过度优化导致的刷新问题
export default ProductCardList;

View File

@@ -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<string, string> = {};
// 批量获取产品图片的函数
export const batchFetchProductImages = async (productIds: string[]): Promise<Record<string, string>> => {
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<string> => {
// 检查缓存
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<ProductImageProps> = React.memo(({
productId,
alt = 'Product Image',
width = '100%',
height = 'auto',
style = {} // 默认空对象
}) => {
const [imageSrc, setImageSrc] = useState<string | null>(null); // 用于存储图片地址
const [isLoading, setIsLoading] = useState<boolean>(false); // 用于管理加载状态
const [isError, setIsError] = useState<boolean>(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 (
<div style={containerStyle}>
<PictureOutlined style={{ fontSize: 48, color: '#999' }} />
<Text style={{ marginTop: 16, color: '#999' }}></Text>
</div>
);
}
// 加载中状态
if (isLoading) {
return (
<div style={containerStyle}>
<LoadingOutlined style={{ fontSize: 48, color: '#999' }} spin />
<Text style={{ marginTop: 16, color: '#999' }}>...</Text>
</div>
);
}
// 加载错误状态,展示 productId
if (isError) {
return (
<div style={containerStyle}>
<PictureOutlined style={{ fontSize: 48, color: '#999' }} />
<Text style={{ marginTop: 16, color: '#999' }}></Text>
<Text style={{ marginTop: 8, color: '#999' }}>ID: {productId}</Text>
</div>
);
}
//如果没有imageSrc。则显示请稍后...
if (!imageSrc) {
return (
<div style={containerStyle}>
<PictureOutlined style={{ fontSize: 48, color: '#999' }} />
<Text style={{ marginTop: 16, color: '#999' }}>...</Text>
</div>
);
}
// 成功加载图片
return (
<Image
src={imageSrc}
alt={alt} // 使用传入的 alt 属性
style={imageStyle}
preview={false} // 禁用预览功能以减少内存消耗
loading="lazy" // 使用原生懒加载
/>
);
});
// 设置组件显示名称,便于调试
ProductImage.displayName = 'ProductImage';
export default ProductImage;

View File

@@ -0,0 +1,4 @@
.customTooltip {
color: #1A202C !important; /* 自定义文字颜色,假设 myGray.800 类似 #1A202C */
--ant-tooltip-arrow-background: white !important; /* 确保箭头颜色和背景一致 */
}

View File

@@ -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<MyTooltipProps> = ({
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'; // 保留 <br /> 标签为换行符
}
// 安全地访问 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 (
<Tooltip
title={
<Paragraph
copyable={{
text: getCopyText(),
onCopy: () => message.success('内容已复制到剪贴板'),
tooltips: ['点击复制', '复制成功']
}}
style={{
margin: 0,
color: '#1A202C',
fontSize: '12px',
whiteSpace: 'pre-wrap'
}}
>
{title}
</Paragraph>
}
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 ? <span>{children}</span> : children}
</Tooltip>
);
};
export default MyTooltip;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';

View File

@@ -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<string, { 物流状态: string, 物流单号?: string }> = {};
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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string, string> = {};
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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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'; // 引入类型定义

View File

@@ -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') {

View File

@@ -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 '处理中'; // 默认状态
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<CombinedResponse | { error: string }>
) {
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<ExtractInfoResponse>(
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<ParseLocationResponse>(
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: '服务器内部错误' });
}
}
}

View File

@@ -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<Kuaidi100Response> {
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<ParsedAddress | { error: string }>
) {
// 只允许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<number, string> = {
[-1]: '解析失败/异常,请稍后重试',
[200]: '提交成功',
[10000]: '解析失败,请检查输入内容',
[10002]: '请求参数错误',
[10007]: '系统内部调用异常',
[10025]: '非法请求,异常文件',
[30002]: '验证签名失败请检查API配置',
[30004]: '账号单量不足,需要充值'
};
return errorMessages[code] || `未知错误 (${code})`;
}

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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';

View File

@@ -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) => {

View File

@@ -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<IProduct[]>([]);
const userInfo = useUserInfo(); // 获取当前用户信息
const [isModalVisible, setIsModalVisible] = useState(false);
const [currentProduct, setCurrentProduct] = useState<IProduct | null>(null);
// 新增筛选状态
const [selectedBrand, setSelectedBrand] = useState<string | undefined>(undefined);
const [selectedCategory, setSelectedCategory] = useState<string | undefined>(undefined);
const [selectedSupplier, setSelectedSupplier] = useState<string | undefined>(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<string>();
products.forEach((product) => {
if (product.?.name) {
brandSet.add(product..name);
}
});
return Array.from(brandSet);
}, [products]);
const categories = useMemo(() => {
const categorySet = new Set<string>();
products.forEach((product) => {
if (product.?.name) {
categorySet.add(product..name);
}
});
return Array.from(categorySet);
}, [products]);
const suppliers = useMemo(() => {
const supplierSet = new Set<string>();
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 (
<div style={{ padding: '16px' }}>
{/* 筛选器 */}
<Space style={{ marginBottom: '16px' }}>
<Select
placeholder="选择品牌"
value={selectedBrand}
onChange={(value) => setSelectedBrand(value)}
allowClear
style={{ width: 200 }}
>
{brands.map((brand) => (
<Option key={brand} value={brand}>
{brand}
</Option>
))}
</Select>
<Select
placeholder="选择品类"
value={selectedCategory}
onChange={(value) => setSelectedCategory(value)}
allowClear
style={{ width: 200 }}
>
{categories.map((category) => (
<Option key={category} value={category}>
{category}
</Option>
))}
</Select>
<Select
placeholder="选择供应商"
value={selectedSupplier}
onChange={(value) => setSelectedSupplier(value)}
allowClear
style={{ width: 200 }}
>
{suppliers.map((supplier) => (
<Option key={supplier} value={supplier}>
{supplier}
</Option>
))}
</Select>
</Space>
<Row gutter={[16, 16]}>
{filteredProducts.map((product) => {
const fullInfo = `${product.?.name || '无'} ${product.}\n售价: ¥${
product.
}\n供应商: ${product.?.?. || '无'}`;
return (
<Col key={product._id} xs={12} sm={8} md={6} lg={3}>
<Card
hoverable
style={{ borderRadius: '8px', overflow: 'hidden' }}
cover={<ProductImage productId={product._id} height={150} />}
actions={[
<Button
key="edit"
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(product)}
size="small"
>
</Button>,
<Popconfirm
key="delete"
title="确定要删除这个吗?"
onConfirm={() => handleDelete(product._id)}
okText="是"
cancelText="否"
placement="left"
>
<Button
type="link"
icon={<DeleteOutlined />}
danger
size="small"
>
</Button>
</Popconfirm>,
]}
>
<Tooltip
title={<span style={{ whiteSpace: 'pre-line' }}>{fullInfo}</span>}
placement="top"
>
<Card.Meta
style={{ padding: '8px 0' }} // 减小内容区域的上下内边距
title={
<div
style={{
fontSize: '14px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{product.?.name || '无'} {product.}
</div>
}
description={
<div>
<Row gutter={[8, 4]}>
<Col span={24}>
<p
style={{
fontSize: '12px',
marginBottom: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
: ¥{product.}
</p>
</Col>
<Col span={24}>
<p
style={{
fontSize: '12px',
marginBottom: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
: {product.?.?. || '无'}
</p>
</Col>
</Row>
</div>
}
/>
</Tooltip>
</Card>
</Col>
);
})}
</Row>
{isModalVisible && (
<ProductModal
visible={isModalVisible}
onOk={handleModalOk}
onCancel={() => setIsModalVisible(false)}
product={currentProduct}
/>
)}
</div>
);
};
export default Test8Page;

View File

@@ -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<ProductModalProps> = ({ visible, onOk, onCancel, product }) => {
const [form] = Form.useForm();
const [brands, setBrands] = useState<IBrand[]>([]);
const [categories, setCategories] = useState<ICategory[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
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<HTMLDivElement>) => {
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<FileReader>) => {
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 (
<Modal
title={product ? '编辑产品' : '添加新产品'}
open={visible}
onOk={handleSubmit}
onCancel={() => {
form.resetFields();
onCancel();
}}
width='80%'
>
<Form
form={form}
layout="vertical"
>
<Row gutter={16}>
<Col span={16}>
{/* 第一行:产品名称 */}
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="名称"
label="产品名称"
rules={[{ required: true, message: '请输入产品名称!' }]}
>
<Input />
</Form.Item>
</Col>
</Row>
{/* 第二行:产品描述、产品货号、产品编码 */}
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="描述"
label="产品描述"
>
<Input />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="货号"
label="产品货号"
>
<Input />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="编码"
label="产品编码"
>
<Input />
</Form.Item>
</Col>
</Row>
{/* 第三行:供应商、品牌、品类 */}
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="供应商"
label="供应商"
rules={[{ required: true, message: '请选择供应商!' }]}
>
<Select placeholder="请选择供应商">
{suppliers.map(supplier => (
<Select.Option key={supplier._id} value={supplier._id}>
{supplier.}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="品牌"
label="品牌"
rules={[{ required: true, message: '请选择品牌!' }]}
>
<Select placeholder="请选择品牌">
{brands.map(brand => (
<Select.Option key={brand._id} value={brand._id}>
{brand.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="品类"
label="品类"
rules={[{ required: true, message: '请选择品类!' }]}
>
<Select placeholder="请选择品类">
{categories.map(category => (
<Select.Option key={category._id} value={category._id}>
{category.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
{/* 第四行:售价、成本价、包装费、运费 */}
<Row gutter={16}>
<Col span={6}>
<Form.Item
name="售价"
label="售价"
rules={[{ required: true, message: '请输入售价!' }]}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="成本价"
label="成本价"
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="包装费"
label="包装费"
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="运费"
label="运费"
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
</Col>
{/* 右侧图片区域 */}
<Col span={8}>
<Form.Item label="图片 - 修改后请刷新表格查看" name="图片">
<div
onPaste={handlePaste}
style={{
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
minHeight: '360px',
border: '1px dashed #d9d9d9',
borderRadius: '8px',
position: 'relative'
}}
>
{productData. ? (
<>
<img
src={productData.}
alt="Product Image"
style={{ maxWidth: '100%', maxHeight: '360px', borderRadius: '8px' }}
/>
<button
onClick={() => setProductData(prev => ({ ...prev, : '' }))}
style={{
position: 'absolute',
top: '10px',
right: '10px',
background: 'rgba(255,255,255,0.8)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'red',
cursor: 'pointer'
}}
>
<CloseOutlined />
</button>
</>
) : (
<div style={{ textAlign: 'center' }}>
<h2></h2>
<p>800*800px分辨率图片</p>
</div>
)}
</div>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
);
};
export default ProductModal;

View File

@@ -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: <RollbackOutlined />, color: '#ff4d4f', text: '退货处理' };
case '换货':
return { icon: <SwapOutlined />, color: '#1890ff', text: '换货处理' };
case '补发':
return { icon: <SendOutlined />, color: '#52c41a', text: '补发处理' };
case '补差':
return { icon: <DiffOutlined />, color: '#faad14', text: '差价处理' };
default:
return { icon: <QuestionCircleOutlined />, color: '#999', text: '售后处理' };
}
};
/**
* 售后记录模态框组件
*/
const AfterSalesModal: React.FC<AfterSalesModalProps> = ({ visible, onOk, onCancel, record, type }) => {
const [form] = Form.useForm();
const [paymentPlatforms, setPaymentPlatforms] = useState<IPaymentPlatform[]>([]);
const [selectedProducts, setSelectedProducts] = useState<IProduct[]>([]);
const [products, setProducts] = useState<IProduct[]>([]);
const [paymentCode, setPaymentCode] = useState<string | null>(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: <CheckCircleOutlined style={{ color: typeConfig.color }} />
});
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<HTMLDivElement>) => {
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 (
<div>
<Card
size="small"
title={
<Space>
{type === '换货' ? <SwapOutlined /> : <SendOutlined />}
<span>{type === '换货' ? '替换产品' : '补发产品'}</span>
</Space>
}
extra={
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => setIsProductModalVisible(true)}
size="small"
>
</Button>
}
style={{ marginBottom: 16 }}
>
{selectedProducts.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{selectedProducts.map(product => (
<Card
key={product._id}
size="small"
hoverable
style={{ width: 120, marginBottom: 8 }}
cover={
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 4 }}>
<ProductImage productId={product._id} alt={product.} width={90} height={90} />
</div>
}
actions={[
<Button
type="text"
danger
icon={<CloseOutlined />}
key="remove"
onClick={() => {
setSelectedProducts(prev => prev.filter(p => p._id !== product._id));
}}
size="small"
/>
]}
>
<Card.Meta
title={<Typography.Text ellipsis={{ tooltip: product.名称 }}>{product.}</Typography.Text>}
description={
<Typography.Text type="secondary">
¥{(product as any).?. || '未知'}
</Typography.Text>
}
style={{ textAlign: 'center' }}
/>
</Card>
))}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={`请添加${type === '换货' ? '替换' : '补发'}产品`}
style={{ margin: '20px 0' }}
/>
)}
<Form.Item
name="替换产品"
style={{ marginBottom: 0, marginTop: 16 }}
rules={[
{
validator: () => {
if (selectedProducts.length === 0) {
return Promise.reject(`请选择${type === '换货' ? '替换' : '补发'}产品`);
}
return Promise.resolve();
}
}
]}
>
<Select
mode="multiple"
placeholder={`请选择${type === '换货' ? '替换' : '补发'}产品`}
onChange={handleProductSelectChange}
style={{ display: 'none' }} // 隐藏实际选择器使用卡片式UI代替
>
{products.map(product => (
<Select.Option key={product._id} value={product._id}>
{product.}
</Select.Option>
))}
</Select>
</Form.Item>
</Card>
</div>
);
}
return null;
};
return (
<Modal
open={visible}
title={
<Space>
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: '50%',
background: typeConfig.color,
color: '#fff'
}}>
{typeConfig.icon}
</span>
<span>{typeConfig.text}</span>
{record && (
<Tag color="blue">{record.?.}</Tag>
)}
</Space>
}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel} disabled={loading}>
</Button>,
<Button
key="submit"
type="primary"
onClick={handleOk}
loading={loading}
icon={typeConfig.icon}
>
{type}
</Button>,
]}
width="90%"
styles={{
body: { maxHeight: '75vh', overflow: 'auto', padding: '16px 24px' }
}}
maskClosable={false}
destroyOnClose={true}
style={{ top: "10%" }}
>
<Form
form={form}
layout="vertical"
requiredMark="optional"
>
<Row gutter={24}>
{/* 左侧:原订单信息 */}
<Col span={8}>
<Card
title={
<Space>
<InfoCircleOutlined />
<span></span>
</Space>
}
size="small"
bordered
style={{ marginBottom: 16 }}
>
{record && (
<div>
<Row style={{ marginBottom: 12 }}>
<Col span={12}>
<Space>
<UserOutlined style={{ color: '#1890ff' }} />
<Text strong>{record.?. || '未知'}</Text>
</Space>
</Col>
<Col span={12}>
<Space>
<PhoneOutlined style={{ color: '#1890ff' }} />
<Text>{record.?. ? `${record...slice(0, 3)}****${record...slice(-4)}` : '无'}</Text>
</Space>
</Col>
</Row>
<Row style={{ marginBottom: 12 }}>
<Col span={12}>
<Space>
<CalendarOutlined style={{ color: '#1890ff' }} />
<Text>: {new Date(record.).toLocaleDateString('zh-CN')}</Text>
</Space>
</Col>
<Col span={12}>
<Space>
<DollarOutlined style={{ color: '#52c41a' }} />
<Text>: ¥{record.?.toFixed(2)}</Text>
</Space>
</Col>
</Row>
<Row style={{ marginBottom: 12 }}>
<Col span={12}>
<Space>
<DollarOutlined style={{ color: '#1890ff' }} />
<Text>: ¥{record.?.toFixed(2)}</Text>
</Space>
</Col>
<Col span={12}>
<Space>
<DollarOutlined style={{ color: record.待收款 ? '#ff4d4f' : '#52c41a' }} />
<Text>: ¥{record.?.toFixed(2)}</Text>
</Space>
</Col>
</Row>
<Divider style={{ margin: '12px 0' }} />
<Space align="center">
<Badge status="processing" color={typeConfig.color} />
<Text>
:
<Text strong style={{ marginLeft: 4 }}>
{Math.ceil((new Date().getTime() - new Date(record.).getTime()) / (1000 * 60 * 60 * 24))}
</Text>
</Text>
</Space>
</div>
)}
</Card>
<Card
title={
<Space>
<RollbackOutlined />
<span></span>
</Space>
}
size="small"
bordered
>
<Form.Item
name="原产品"
rules={[{ required: true, message: '请选择需要售后的产品' }]}
>
<Select
mode="multiple"
placeholder="请选择原订单中的产品"
optionLabelProp="label"
style={{ width: '100%' }}
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
>
{record?..map(product => (
<Select.Option
key={product._id}
value={product._id}
label={product.}
>
<div style={{ display: 'flex', alignItems: 'center', padding: 4 }}>
<div style={{ width: 32, height: 32, marginRight: 8, overflow: 'hidden', flexShrink: 0 }}>
<ProductImage
productId={product._id}
alt={product.}
width={32}
height={32}
/>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{product.}</div>
<Text type="secondary" style={{ fontSize: 12 }}>
¥{(product as any).?. || '未知'}
</Text>
</div>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="售后原因"
name="原因"
rules={[{ required: true, message: '请选择售后原因' }]}
>
<Select
placeholder="请选择售后原因"
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
>
<Select.Option value="发货原因"></Select.Option>
<Select.Option value="产品质量"></Select.Option>
<Select.Option value="择优选购"></Select.Option>
<Select.Option value="七日退换">退</Select.Option>
<Select.Option value="货不对板"></Select.Option>
<Select.Option value="运输损坏"></Select.Option>
<Select.Option value="配件缺失"></Select.Option>
</Select>
</Form.Item>
<Form.Item
name="日期"
label="售后日期"
rules={[{ required: true, message: '请选择售后日期' }]}
>
<DatePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
suffixIcon={<CalendarOutlined />}
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
/>
</Form.Item>
</Card>
</Col>
{/* 中间:替换产品信息 */}
<Col span={8}>
{renderReplacementProductsSection()}
<Card
title={
<Space>
<FileTextOutlined />
<span></span>
</Space>
}
size="small"
bordered
>
<Form.Item name="备注">
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 8 }}
placeholder="请输入售后处理的备注信息"
showCount
maxLength={500}
/>
</Form.Item>
</Card>
</Col>
{/* 右侧:财务信息和收款码 */}
<Col span={8}>
<Card
title={
<Space>
<DollarOutlined />
<span></span>
</Space>
}
size="small"
bordered
style={{ marginBottom: 16 }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="收支平台"
label="收支平台"
rules={[
{ required: true, message: '请选择收支平台' }
]}
>
<Select
placeholder="请选择收支平台"
allowClear
suffixIcon={<BankOutlined />}
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
>
{paymentPlatforms.map(platform => (
<Select.Option key={platform._id} value={platform._id}>
{platform.}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="收支类型"
name="收支类型"
rules={[
{ required: true, message: '请选择收支类型' }
]}
>
<Select
placeholder="请选择收支类型"
allowClear
suffixIcon={<DollarOutlined />}
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
>
<Option value="收入">
<Space>
<span style={{ color: '#52c41a' }}></span>
<span></span>
</Space>
</Option>
<Option value="支出">
<Space>
<span style={{ color: '#ff4d4f' }}></span>
<span></span>
</Space>
</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="收支金额"
label={
<Tooltip title={`${type === '退货' ? '退款给客户的金额' : type === '补差' ? '补收或退给客户的差价' : '相关收支金额'}`}>
<Space>
<span></span>
<InfoCircleOutlined style={{ fontSize: 14, color: '#1890ff' }} />
</Space>
</Tooltip>
}
rules={[
{ required: true, message: '请输入金额' }
]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
precision={2}
prefix={<DollarOutlined />}
placeholder="请输入金额"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={
<Tooltip title="待收金额">
<Space>
<span></span>
<InfoCircleOutlined style={{ fontSize: 14, color: '#1890ff' }} />
</Space>
</Tooltip>
}
name="待收"
>
<InputNumber
min={0}
style={{ width: '100%' }}
precision={2}
prefix={<DollarOutlined />}
placeholder="待收金额"
/>
</Form.Item>
</Col>
</Row>
</Card>
<Card
title={
<Space>
<PictureOutlined />
<span></span>
</Space>
}
size="small"
bordered
>
<Form.Item>
<div
onPaste={handlePaste}
style={{
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
minHeight: 260,
border: '1px dashed #d9d9d9',
borderRadius: 8,
background: '#fafafa',
transition: 'all 0.3s',
position: 'relative'
}}
onMouseOver={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.background = '#f0f5ff';
}}
onMouseOut={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.background = '#fafafa';
}}
>
{paymentCode ? (
<div style={{ position: 'relative', width: '100%', textAlign: 'center' }}>
<img
src={paymentCode}
alt="Payment Code"
style={{
maxWidth: '100%',
maxHeight: 260,
borderRadius: 4
}}
/>
<Button
type="primary"
danger
shape="circle"
icon={<CloseOutlined />}
onClick={() => setPaymentCode(null)}
style={{
position: 'absolute',
top: -10,
right: -10,
}}
size="small"
/>
</div>
) : (
<div style={{ textAlign: 'center' }}>
<p><UploadOutlined style={{ fontSize: 32, color: '#bfbfbf' }} /></p>
<p style={{ color: '#888', marginTop: 8 }}></p>
<p style={{ color: '#bfbfbf', fontSize: 12 }}> Ctrl+V </p>
</div>
)}
</div>
</Form.Item>
</Card>
</Col>
</Row>
</Form>
{/* 产品添加模态框
<AddProductComponent
visible={isProductModalVisible}
onClose={() => setIsProductModalVisible(false)}
onSuccess={handleAddProductSuccess}
/> */}
</Modal>
);
};
export default AfterSalesModal;

View File

@@ -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<ISalesRecord | null>(null);
const [loading, setLoading] = useState(false);
const [salesRecords, setSalesRecords] = useState<ISalesRecord[]>([]);
const [afterSalesType, setAfterSalesType] = useState<typeof AFTER_SALES_TYPES[number] | null>(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<string>();
const recordIds = new Set<string>();
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<ISalesRecord>[] = useMemo(() => [
const columns: TableColumnType<ISalesRecord>[] = useMemo(() => {
const baseColumns: TableColumnType<ISalesRecord>[] = [
{
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 (
<div style={{
...COMMON_STYLES.flexColumn,
alignItems: 'center',
justifyContent: 'center'
}}>
{/* 第一行:导购 */}
<Tag
icon={<UserOutlined />}
color="green"
style={{
...COMMON_STYLES.tagContainer,
fontWeight: 'bold',
marginBottom: '3px',
fontSize: '13px'
}}
>
{guideName}
</Tag>
{/* 第二行:账号编号和微信号 */}
<div style={{
display: 'flex',
gap: '3px',
marginBottom: '3px',
justifyContent: 'center'
}}>
<Tag
icon={<MobileOutlined />}
color="blue"
style={{
margin: 0,
fontSize: '12px',
padding: '1px 4px'
}}
>
{accountNumber}
</Tag>
<Tag
icon={<WechatOutlined />}
color="cyan"
style={{
margin: 0,
fontSize: '12px',
padding: '1px 6px'
}}
>
{wechatId}
</Tag>
</div>
{/* 第三行:成交日期 */}
<Tag
icon={<FieldTimeOutlined />}
color="orange"
style={{
margin: 0,
fontSize: '12px',
marginBottom: '2px',
padding: '1px 6px'
}}
>
: {transactionDate}
</Tag>
{/* 第四行:创建日期时间 */}
<Tag
icon={<FieldTimeOutlined />}
color="purple"
style={{
margin: 0,
fontSize: '12px',
padding: '1px 6px'
}}
>
: {createdDateTime}
</Tag>
</div>
);
},
},
{
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 (
<div style={{ display: "flex" }}>
{/* 左侧复制按钮 */}
<div style={{ marginRight: 8 }}>
{isAdmin && (
<Paragraph
copyable={{
text: customerCopyText,
onCopy: () => message.success(`客户 ${customerName} 信息复制成功!`),
tooltips: ['复制客户信息', '复制成功'],
icon: <Iconify icon="eva:copy-fill" size={16} />
}}
style={{ margin: 0, lineHeight: 0 }}
>
{/* 空内容,只显示复制按钮 */}
</Paragraph>
)}
</div>
{/* 右侧标签信息,每行一个标签 */}
<MyTooltip color="white" title={customerName} placement="topLeft">
<div style={COMMON_STYLES.flexColumn}>
<Tag icon={<UserOutlined />} color={customerTagColor} style={COMMON_STYLES.tagContainer}>
{customerName}
</Tag>
<Tag icon={<MobileOutlined />} color="green" style={COMMON_STYLES.tagContainer}>
{record.?. ? `${record...slice(-4)}` : "未知"}
</Tag>
<Tag icon={<FieldTimeOutlined />} color="purple" style={COMMON_STYLES.tagContainer}>
{record.?. ? formatDate(record..) : "未知"}
</Tag>
<Tag color="orange" style={COMMON_STYLES.tagContainer}>
{diffDays}
</Tag>
</div>
</MyTooltip>
</div>
);
},
},
//备注
{
title: "备注",
key: "备注",
dataIndex: "备注",
width: 260,
render: (text: string, record: any) => {
// 获取产品列表
const products = record. || [];
const fetchBase64ImageAsBlob = async (
productId: string
): Promise<Blob> => {
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 (
<div style={COMMON_STYLES.flexRow}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<Tooltip title="复制发货信息">
<IconButton
style={COMMON_STYLES.copyButton}
onClick={async () => {
try {
if (products.length > 0) {
try {
// 尝试复制第一款产品的图片
const productId = products[0]._id;
const blob = await fetchBase64ImageAsBlob(productId);
const clipboardItems: Record<string, Blob | string> = {
"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("复制信息失败");
}
}}
>
<Iconify icon="eva:copy-outline" size={16} />
</IconButton>
</Tooltip>
</div>
{/* 备注内容 */}
<MyTooltip title={text} color="white" placement="topLeft">
<div
style={{
lineHeight: "1.2",
fontSize: "1.0em",
}}
>
{text || "无备注"}
</div>
</MyTooltip>
</div>
);
},
},
//使用<ProductCardList products={products} record={salesRecord} />替换,显示产品信息
{
title: "产品信息",
width: 420, // 增加宽度以适应3个产品 (130px * 3 + 间距)
dataIndex: "产品",
key: "productImage",
render: (products: any[], record: ISalesRecord) => (
<div style={COMMON_STYLES.scrollContainer}>
<ProductCardList products={products} record={record} />
</div>
),
},
{
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 (
<div
style={{ display: "flex", flexDirection: "column", gap: "4px" }}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>{paymentPlatform}</span>
<span style={{ fontWeight: "bold" }}>
: ¥{receivableAmount}
</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>{paymentStatus}</span>
<span style={{ fontWeight: "bold" }}>
: ¥{receivedAmount}
</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>:</span>
<span style={{ fontWeight: "bold" }}>
¥{record.?. ?? 0}
</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>:</span>
<div>
{record.?.map((coupon: ICoupon) => (
<MyTooltip
//title={`类型:${coupon._id.优惠券类型}\n金额${coupon._id.金额}\n折扣${coupon._id.折扣}`}
color="white"
title={
<>
{coupon._id.}
<br />
{coupon._id.}
<br />
{coupon._id.}
</>
}
>
<Tag key={coupon._id._id} color="blue">
{coupon._id.}
</Tag>
</MyTooltip>
))}
</div>
</div>
<div
style={{
display: "flex",
//小号字体
fontSize: "12px",
justifyContent: "space-between",
}}
>
<span>
<span
style={{
fontWeight: "bold",
//1.6倍字体
fontSize: "1.3em",
color:
parseFloat(unreceivedAmount) > 0
? "#ff4d4f"
: "inherit", // 只有大于0时才显示红色
}}
>
¥{unreceivedAmount}
</span>
</span>
<div style={{ display: "flex", gap: "8px" }}>
<span>: </span>
<span style={{ fontWeight: "bold" }}>
¥{receivedPendingAmount}
</span>
</div>
</div>
</div>
);
},
},
];
// 如果用户不是财务角色,添加操作列
if (isNotFinanceRole) {
baseColumns.push({
title: "操作",
key: "action",
align: "center",
fixed: "right",
width: 160,
render: (_: any, record: ISalesRecord) => (
<div style={{ ...COMMON_STYLES.flexColumn, alignItems: "center", gap: "8px" }}>
<div style={COMMON_STYLES.flexRow}>
{AFTER_SALES_TYPES.map((type) => (
<Tooltip key={type} title={`创建${type}记录`}>
<Button
size="small"
onClick={() => modalHandlers.showAfterSales(record, type)}
>
{type.charAt(0)}
</Button>
</Tooltip>
))}
</div>
<div style={COMMON_STYLES.flexRow}>
<Button size="small" type="primary" onClick={() => modalHandlers.showShip(record)}>
</Button>
<Button size="small" type="primary" onClick={() => modalHandlers.showEdit(record)}>
</Button>
</div>
</div>
),
});
}
return baseColumns;
}, [isNotFinanceRole, isAdmin, userInfo]);
return (
// 外层容器 - 占据全部可用空间
<div style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
}}>
<Table
sticky
title={undefined}
scroll={{
// 表格高度 - 自动计算减去分页器高度,确保表格占满整个容器
y: 'calc(100vh - 160px)', // 减去头部、分页器等组件的高度
// 表格宽度 - 支持水平滚动,内容超出时可滚动
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={salesRecords}
rowKey="_id"
// 添加行渲染优化
rowClassName={(_, index) => (index % 2 === 0 ? 'even-row' : 'odd-row')}
// 表格样式 - 确保占满容器
style={{
height: '100%',
width: '100%'
}}
/>
<Suspense fallback={<Skeleton active />}>
{modals.edit && (
<EditSalesModal
visible={modals.edit}
onOk={() => {
modalHandlers.closeAll();
modalHandlers.handleSuccess();
}}
onCancel={modalHandlers.closeAll}
record={currentRecord}
/>
)}
{modals.afterSales && (
<AfterSalesModal
visible={modals.afterSales}
onOk={() => {
modalHandlers.closeAll();
modalHandlers.handleSuccess();
}}
onCancel={modalHandlers.closeAll}
record={currentRecord}
type={afterSalesType!}
/>
)}
{modals.ship && (
<ShipModal
visible={modals.ship}
onOk={modalHandlers.closeAll}
onCancel={modalHandlers.closeAll}
record={currentRecord}
/>
)}
</Suspense>
</div>
);
};
export default SalesPage;

View File

@@ -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<string | null>(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 (
<div style={{ display: 'flex', alignItems: 'center', padding: '4px 0' }}>
{/* 产品图片 */}
<div
style={{
width: 36,
height: 36,
marginRight: 12,
borderRadius: 4,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
flexShrink: 0
}}
>
{!imageLoaded ? (
<PictureOutlined style={{ fontSize: 18, color: '#bfbfbf' }} />
) : (
<img
src={imageSrc || ''}
alt={product.}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
)}
</div>
{/* 产品信息 */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{product.}
</div>
<Text type="secondary" style={{ fontSize: '12px' }}>
¥{(product as any).?. || '未知'}
</Text>
</div>
</div>
);
};
/**
* 销售记录编辑模态框组件
*/
const SalesModal: React.FC<SalesModalProps> = ({ visible, onOk, onCancel, record }) => {
const [form] = Form.useForm();
const [customers, setCustomers] = useState<ICustomer[]>([]);
const [products, setProducts] = useState<IProduct[]>([]);
const [paymentPlatforms, setPaymentPlatforms] = useState<IPaymentPlatform[]>([]);
const userInfo = useUserInfo(); // 获取当前用户信息
const [users, setUsers] = useState<any[]>([]);
// 加载状态
const [loading, setLoading] = useState<boolean>(false);
const [submitting, setSubmitting] = useState<boolean>(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 (
<Modal
open={visible}
title={
<Space>
<ShoppingCartOutlined />
{record ? '编辑订单记录' : '添加订单记录'}
</Space>
}
onCancel={onCancel}
width={700}
styles={{
body: { maxHeight: '75vh', overflow: 'auto', padding: '12px 24px' }
}}
footer={[
<Button key="cancel" onClick={onCancel} disabled={submitting}>
</Button>,
<Button
key="submit"
type="primary"
onClick={handleOk}
loading={submitting}
disabled={loading}
>
</Button>,
]}
maskClosable={false}
destroyOnClose={true}
>
<Spin spinning={loading}>
<div style={{ minHeight: '200px' }}>
{loading && <div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>...</div>}
<Form
form={form}
layout="vertical"
requiredMark="optional"
scrollToFirstError
>
{/* 基本信息区块 */}
<Card
size="small"
title={<Title level={5}></Title>}
style={{ marginBottom: 16 }}
>
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="导购"
label="导购"
rules={[{ required: true, message: '请选择导购' }]}
>
<Select
placeholder="请选择导购"
showSearch
optionFilterProp="children"
suffixIcon={<UserOutlined />}
>
{users.map(user => (
<Select.Option key={user._id} value={user._id}>
{user.}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="客户"
label="客户"
rules={[{ required: true, message: '请选择客户' }]}
>
<Select
placeholder="请选择客户"
showSearch
optionFilterProp="children"
suffixIcon={<UserOutlined />}
>
{customers.map(customer => (
<Select.Option key={customer._id} value={customer._id}>
{customer.} {customer. ? `(${customer..slice(-4)})` : ''}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="产品"
label="产品"
rules={[{ required: true, message: '请选择产品' }]}
>
<Select
mode="multiple"
placeholder="请选择产品"
showSearch
optionFilterProp="children"
suffixIcon={<ShoppingCartOutlined />}
listHeight={280}
style={{ width: '100%' }}
optionLabelProp="label"
>
{products.map(product => (
<Option
key={product._id}
value={product._id}
label={product.}
>
<ProductSelectOption product={product} />
</Option>
))}
</Select>
</Form.Item>
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="成交日期"
label="成交日期"
rules={[{ required: true, message: '请选择成交日期' }]}
>
<DatePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
suffixIcon={<CalendarOutlined />}
allowClear={false}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="备注"
label="备注"
>
<Input.TextArea
rows={1}
placeholder="备注信息"
maxLength={200}
showCount
/>
</Form.Item>
</Col>
</Row>
</Card>
{/* 财务信息区块 */}
<Card
size="small"
title={<Title level={5}></Title>}
style={{ marginBottom: 16 }}
>
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="应收金额"
label="应收金额"
rules={[{ required: true, message: '请输入应收金额' }]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
precision={2}
prefix={<DollarOutlined />}
step={10}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="收款平台"
label="收款平台"
rules={[{ required: true, message: '请选择收款平台' }]}
>
<Select
placeholder="请选择收款平台"
suffixIcon={<BankOutlined />}
>
{paymentPlatforms.map(platform => (
<Select.Option key={platform._id} value={platform._id}>
{platform.}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="收款状态"
label="收款状态"
rules={[{ required: true, message: '请选择收款状态!' }]}
>
<Select
placeholder="请选择收款状态"
suffixIcon={<CreditCardOutlined />}
>
<Select.Option value="全款"></Select.Option>
<Select.Option value="定金"></Select.Option>
<Select.Option value="未付"></Select.Option>
<Select.Option value="赠送"></Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="收款金额"
label="收款金额"
rules={[{ required: true, message: '请输入收款金额' }]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
precision={2}
prefix={<DollarOutlined />}
step={10}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="待收款"
label={
<Tooltip title="应收金额减去收款金额的差额">
<Space>
<span></span>
<Text type="secondary">(-)</Text>
</Space>
</Tooltip>
}
rules={[{ required: true, message: '请输入待收款金额' }]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
precision={2}
disabled={paymentStatus === '全款'}
prefix={<DollarOutlined />}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="待收已收"
label={
<Tooltip title="待收款中已收到的部分">
<Space>
<span></span>
<Text type="secondary">()</Text>
</Space>
</Tooltip>
}
>
<InputNumber
min={0}
style={{ width: '100%' }}
precision={2}
disabled={paymentStatus === '全款'}
prefix={<DollarOutlined />}
/>
</Form.Item>
</Col>
</Row>
</Card>
</Form>
</div>
</Spin>
</Modal>
);
};
export default SalesModal;

View File

@@ -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<ShipModalProps> = ({ 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 (
<Modal
open={visible}
title="发货信息"
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button key="submit" type="primary" onClick={handleOk}>
</Button>,
]}
>
<Form form={form} layout="vertical">
<Form.Item
name="客户尾号"
label="客户尾号"
rules={[{ required: true, message: '请输入客户电话尾号' }]}
>
<Input placeholder="自动填入客户电话尾号" disabled />
</Form.Item>
{/* 动态生成每个产品的物流单号输入框 */}
{record?.?.map(product => (
<Form.Item
key={product._id}
label={`物流单号 (${product.})`}
>
<Input
placeholder={`请输入${product.}的物流单号`}
value={logisticsNumbers[product._id] || ''}
//onChange={e => handleLogisticsNumberChange(product._id, e.target.value)}
onChange={e => {
const cleanedValue = e.target.value.replace(/[\s.,/#!$%\^&\*;:{}=\-_`~()<>[\]'"|\\?@+]/g, '');//过滤特殊字符和空格
handleLogisticsNumberChange(product._id, cleanedValue);
}}
/>
</Form.Item>
))}
</Form>
</Modal>
);
};
export default ShipModal;

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

@@ -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; // 抛出错误让调用者处理
}
}

View File

@@ -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;
}
}