This commit is contained in:
372
docs/antd-copy-message-guide.md
Normal file
372
docs/antd-copy-message-guide.md
Normal 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): 初始版本,包含基础使用方法和最佳实践
|
||||
|
||||
---
|
||||
|
||||
> 💡 **提示**:遵循本文档的最佳实践,可以避免常见的警告和错误,提供更好的用户体验。
|
||||
@@ -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
11
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
73
src/components/logistics/LogisticsQuery.tsx
Normal file
73
src/components/logistics/LogisticsQuery.tsx
Normal 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;
|
||||
251
src/components/logistics/status.tsx
Normal file
251
src/components/logistics/status.tsx
Normal 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;
|
||||
299
src/components/product/ProductCardList.tsx
Normal file
299
src/components/product/ProductCardList.tsx
Normal 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;
|
||||
203
src/components/product/ProductImage.tsx
Normal file
203
src/components/product/ProductImage.tsx
Normal 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;
|
||||
4
src/components/tooltip/MyTooltip.module.css
Normal file
4
src/components/tooltip/MyTooltip.module.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.customTooltip {
|
||||
color: #1A202C !important; /* 自定义文字颜色,假设 myGray.800 类似 #1A202C */
|
||||
--ant-tooltip-arrow-background: white !important; /* 确保箭头颜色和背景一致 */
|
||||
}
|
||||
98
src/components/tooltip/MyTooltip.tsx
Normal file
98
src/components/tooltip/MyTooltip.tsx
Normal 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;
|
||||
99
src/pages/api/backstage/sales/Records/[id].ts
Normal file
99
src/pages/api/backstage/sales/Records/[id].ts
Normal 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);
|
||||
76
src/pages/api/backstage/sales/Records/index.ts
Normal file
76
src/pages/api/backstage/sales/Records/index.ts
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
|
||||
61
src/pages/api/logistics/batchStatus.ts
Normal file
61
src/pages/api/logistics/batchStatus.ts
Normal 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;
|
||||
60
src/pages/api/logistics/details.ts
Normal file
60
src/pages/api/logistics/details.ts
Normal 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;
|
||||
88
src/pages/api/logistics/status.ts
Normal file
88
src/pages/api/logistics/status.ts
Normal 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;
|
||||
49
src/pages/api/products/batchImages.ts
Normal file
49
src/pages/api/products/batchImages.ts
Normal 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;
|
||||
57
src/pages/api/products/images/[id].ts
Normal file
57
src/pages/api/products/images/[id].ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'; // 引入类型定义
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
92
src/pages/api/tools/SFExpress/updateLogisticsDetails.ts
Normal file
92
src/pages/api/tools/SFExpress/updateLogisticsDetails.ts
Normal 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 '处理中'; // 默认状态
|
||||
}
|
||||
}
|
||||
27
src/pages/api/tools/logistics/detail/[id].ts
Normal file
27
src/pages/api/tools/logistics/detail/[id].ts
Normal 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);
|
||||
84
src/pages/api/tools/logistics/index.ts
Normal file
84
src/pages/api/tools/logistics/index.ts
Normal 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;
|
||||
133
src/pages/api/tools/parseAddress.ts
Normal file
133
src/pages/api/tools/parseAddress.ts
Normal 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: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
}
|
||||
239
src/pages/api/tools/parseAddressKuaidi100.ts
Normal file
239
src/pages/api/tools/parseAddressKuaidi100.ts
Normal 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})`;
|
||||
}
|
||||
51
src/pages/api/tools/test4.ts
Normal file
51
src/pages/api/tools/test4.ts
Normal 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);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
284
src/pages/backstage/product/index.tsx
Normal file
284
src/pages/backstage/product/index.tsx
Normal 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;
|
||||
344
src/pages/backstage/product/product-modal.tsx
Normal file
344
src/pages/backstage/product/product-modal.tsx
Normal 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;
|
||||
847
src/pages/team/SaleRecord/AfterSalesModal.tsx
Normal file
847
src/pages/team/SaleRecord/AfterSalesModal.tsx
Normal 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;
|
||||
752
src/pages/team/SaleRecord/index.tsx
Normal file
752
src/pages/team/SaleRecord/index.tsx
Normal 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;
|
||||
565
src/pages/team/SaleRecord/sales-modal.tsx
Normal file
565
src/pages/team/SaleRecord/sales-modal.tsx
Normal 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;
|
||||
146
src/pages/team/SaleRecord/ship-modal.tsx
Normal file
146
src/pages/team/SaleRecord/ship-modal.tsx
Normal 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;
|
||||
1397
src/pages/team/SaleRecord/test.tsx
Normal file
1397
src/pages/team/SaleRecord/test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
35
src/utils/getAccessToken.ts
Normal file
35
src/utils/getAccessToken.ts
Normal 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; // 抛出错误让调用者处理
|
||||
}
|
||||
}
|
||||
48
src/utils/querySFExpress.ts
Normal file
48
src/utils/querySFExpress.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user