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/pro-components": "^2.8.7",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@iconify/react": "^4.1.1",
|
"@iconify/react": "^4.1.1",
|
||||||
|
"@types/lodash": "^4.17.17",
|
||||||
"antd": "^5.25.4",
|
"antd": "^5.25.4",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"geist": "^1.4.2",
|
"geist": "^1.4.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"mongoose": "^8.15.1",
|
"mongoose": "^8.15.1",
|
||||||
"next": "15.3.3",
|
"next": "15.3.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@iconify/react':
|
'@iconify/react':
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1(react@19.1.0)
|
version: 4.1.1(react@19.1.0)
|
||||||
|
'@types/lodash':
|
||||||
|
specifier: ^4.17.17
|
||||||
|
version: 4.17.17
|
||||||
antd:
|
antd:
|
||||||
specifier: ^5.25.4
|
specifier: ^5.25.4
|
||||||
version: 5.25.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 5.25.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -35,6 +38,9 @@ importers:
|
|||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.2
|
specifier: ^9.0.2
|
||||||
version: 9.0.2
|
version: 9.0.2
|
||||||
|
lodash:
|
||||||
|
specifier: ^4.17.21
|
||||||
|
version: 4.17.21
|
||||||
mongoose:
|
mongoose:
|
||||||
specifier: ^8.15.1
|
specifier: ^8.15.1
|
||||||
version: 8.15.1
|
version: 8.15.1
|
||||||
@@ -641,6 +647,9 @@ packages:
|
|||||||
'@types/jsonwebtoken@9.0.9':
|
'@types/jsonwebtoken@9.0.9':
|
||||||
resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==}
|
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':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
@@ -2071,6 +2080,8 @@ snapshots:
|
|||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
'@types/node': 20.17.57
|
'@types/node': 20.17.57
|
||||||
|
|
||||||
|
'@types/lodash@4.17.17': {}
|
||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
'@types/node@20.17.57':
|
'@types/node@20.17.57':
|
||||||
|
|||||||
@@ -312,7 +312,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
prefixCls="my-prefix"
|
prefixCls="my-prefix"
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
location={{ pathname: router.pathname }}
|
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"
|
siderMenuType="group"
|
||||||
menu={{
|
menu={{
|
||||||
request: async () => dynamicRoutes,
|
request: async () => dynamicRoutes,
|
||||||
@@ -351,20 +362,36 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
locale="zh-CN"
|
locale="zh-CN"
|
||||||
>
|
>
|
||||||
<PageContainer
|
<PageContainer
|
||||||
|
// 移除默认的页面标题栏
|
||||||
header={{ title: null }}
|
header={{ title: null }}
|
||||||
|
// 完全禁用页面头部渲染 - 确保没有额外的头部空间占用
|
||||||
|
pageHeaderRender={false}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
// 移除 PageContainer 自身的内边距
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
|
// PageContainer 级别的内边距控制 - 双重保险移除所有内边距
|
||||||
|
token={{
|
||||||
|
// 移除内容区域的上下内边距 (垂直方向)
|
||||||
|
paddingBlockPageContainerContent: 0,
|
||||||
|
// 移除内容区域的左右内边距 (水平方向)
|
||||||
|
// 与 ProLayout 的 token 配置配合,确保完全移除左右空白
|
||||||
|
paddingInlinePageContainerContent: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 最内层容器 - 实际承载页面内容的容器 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
padding: '16px',
|
// 最终的边距控制 - 设置为 0 完全移除所有内边距
|
||||||
|
// 这是移除左右空白的最后一道防线
|
||||||
|
// 可以根据需要调整:如 '16px' 恢复默认边距,'0 16px' 只保留左右边距等
|
||||||
|
padding: '0px',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
boxSizing: 'border-box',
|
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 type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { User } from '@/models';
|
import { User } from '@/models';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import connectDB from '@/utils/ConnectDB';
|
import connectDB from '@/utils/connectDB';
|
||||||
import { buildPermissionTree } from './buildPermissionTree';
|
import { buildPermissionTree } from './buildPermissionTree';
|
||||||
import type { IPermission } from '@/models/types';
|
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
|
//src\pages\api\roles\[id].ts
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { Role } from '@/models';
|
import { Role } from '@/models';
|
||||||
import connectDB from '@/utils/ConnectDB';
|
import connectDB from '@/utils/connectDB';
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const { query: { id }, method } = req;
|
const { query: { id }, method } = req;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//src\pages\api\roles\index.ts
|
//src\pages\api\roles\index.ts
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { Role } from '@/models';
|
import { Role } from '@/models';
|
||||||
import connectDB from '@/utils/ConnectDB';
|
import connectDB from '@/utils/connectDB';
|
||||||
import { buildPermissionTree } from '@/pages/api/buildPermissionTree';
|
import { buildPermissionTree } from '@/pages/api/buildPermissionTree';
|
||||||
import { IPermission } from '@/models/types';
|
import { IPermission } from '@/models/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//src\pages\api\team\create.ts
|
//src\pages\api\team\create.ts
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import connectDB from '@/utils/ConnectDB'; // 确保数据库连接
|
import connectDB from '@/utils/connectDB'; // 确保数据库连接
|
||||||
import { Team, User } from '@/models'; // 引入 Team 和 User 模型
|
import { Team, User } from '@/models'; // 引入 Team 和 User 模型
|
||||||
import { ITeam } from '@/models/types'; // 引入类型定义
|
import { ITeam } from '@/models/types'; // 引入类型定义
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { Team } from '@/models';
|
import { Team } from '@/models';
|
||||||
import connectDB from '@/utils/ConnectDB';
|
import connectDB from '@/utils/connectDB';
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method === 'GET') {
|
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 { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { User } from '@/models'; // 用户模型存储在 models 目录中
|
import { User } from '@/models'; // 用户模型存储在 models 目录中
|
||||||
import connectDB from '@/utils/ConnectDB'; // 数据库连接工具
|
import connectDB from '@/utils/connectDB'; // 数据库连接工具
|
||||||
import { buildPermissionTree } from './buildPermissionTree'; // 存在的权限树构建工具
|
import { buildPermissionTree } from './buildPermissionTree'; // 存在的权限树构建工具
|
||||||
|
|
||||||
export default connectDB(async (req: NextApiRequest, res: NextApiResponse) => {
|
export default connectDB(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//src\pages\api\users\[id].ts
|
//src\pages\api\users\[id].ts
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { User } from '@/models';
|
import { User } from '@/models';
|
||||||
import connectDB from '@/utils/ConnectDB';
|
import connectDB from '@/utils/connectDB';
|
||||||
import { IUser } from '@/models/types'; // 导入 IUser 接口类型
|
import { IUser } from '@/models/types'; // 导入 IUser 接口类型
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//src\pages\api\users\index.ts
|
//src\pages\api\users\index.ts
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { User } from '@/models';
|
import { User } from '@/models';
|
||||||
import connectDB from '@/utils/ConnectDB';
|
import connectDB from '@/utils/connectDB';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
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水合过程中的主题闪烁,采用分层过渡策略 */
|
/* 关键代码行注释:防止SSR水合过程中的主题闪烁,采用分层过渡策略 */
|
||||||
html {
|
/* 注意:主要的背景设置已移动到下方的html选择器中,这里仅保留基础设置 */
|
||||||
background-color: #f6f9fc;
|
|
||||||
transition: background-color 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
background-color: #0a1128;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 全局变量系统 ==================== */
|
/* ==================== 全局变量系统 ==================== */
|
||||||
/* 关键代码行注释:统一在一个:root中定义所有全局变量,提高可维护性 */
|
/* 关键代码行注释:统一在一个:root中定义所有全局变量,提高可维护性 */
|
||||||
@@ -154,7 +147,17 @@ body {
|
|||||||
transition: var(--transition-slow);
|
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 {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -162,13 +165,10 @@ body {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
/* 关键代码行注释:使用变量方式应用背景,确保背景图片能正确显示 */
|
/* 关键代码行注释:body元素只设置文字颜色,不设置背景 */
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--bg-primary);
|
/* 关键代码行注释:移除body的背景设置,让html的背景透过 */
|
||||||
background-image: var(--bg-gradient);
|
background: transparent;
|
||||||
background-attachment: fixed;
|
|
||||||
background-size: 100% 100%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
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