diff --git a/next.config.ts b/next.config.ts index ddfb53b..0a306f8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,7 @@ const nextConfig: NextConfig = { /* config options here */ reactStrictMode: true, - // 启用 standalone 输出模式,用于 Docker 部署 + // 启用 standalone 输出模式,用于 Docker 部署(Windows 本地开发时可能需要注释掉) output: 'standalone', // 优化生产构建 diff --git a/package.json b/package.json index e5eed95..b743153 100644 --- a/package.json +++ b/package.json @@ -16,26 +16,33 @@ "@iconify/react": "^4.1.1", "@types/lodash": "^4.17.17", "antd": "^5.25.4", + "apexcharts": "^4.7.0", "bcryptjs": "^3.0.2", + "color": "^5.0.0", "dayjs": "^1.11.13", "geist": "^1.4.2", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "mongoose": "^8.15.1", "next": "15.3.3", + "ramda": "^0.30.1", "react": "^19.0.0", + "react-apexcharts": "^1.7.0", "react-dom": "^19.0.0", "react-error-boundary": "^6.0.0", "react-icons": "^5.5.0", "styled-components": "^6.0.9", + "uuid": "^11.1.0", "zustand": "^5.0.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20", + "@types/ramda": "^0.30.2", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bac4d4..3eafbbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,15 @@ importers: antd: specifier: ^5.25.4 version: 5.25.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + apexcharts: + specifier: ^4.7.0 + version: 4.7.0 bcryptjs: specifier: ^3.0.2 version: 3.0.2 + color: + specifier: ^5.0.0 + version: 5.0.0 dayjs: specifier: ^1.11.13 version: 1.11.13 @@ -50,9 +56,15 @@ importers: next: specifier: 15.3.3 version: 15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + ramda: + specifier: ^0.30.1 + version: 0.30.1 react: specifier: ^19.0.0 version: 19.1.0 + react-apexcharts: + specifier: ^1.7.0 + version: 1.7.0(apexcharts@4.7.0)(react@19.1.0) react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) @@ -65,6 +77,9 @@ importers: styled-components: specifier: ^6.0.9 version: 6.1.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + uuid: + specifier: ^11.1.0 + version: 11.1.0 zustand: specifier: ^5.0.5 version: 5.0.5(@types/react@19.1.6)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)) @@ -78,12 +93,18 @@ importers: '@types/node': specifier: ^20 version: 20.17.57 + '@types/ramda': + specifier: ^0.30.2 + version: 0.30.2 '@types/react': specifier: ^19 version: 19.1.6 '@types/react-dom': specifier: ^19 version: 19.1.5(@types/react@19.1.6) + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 tailwindcss: specifier: ^4 version: 4.1.8 @@ -553,6 +574,31 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' + '@svgdotjs/svg.draggable.js@3.0.6': + resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==} + peerDependencies: + '@svgdotjs/svg.js': ^3.2.4 + + '@svgdotjs/svg.filter.js@3.0.9': + resolution: {integrity: sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==} + engines: {node: '>= 0.8.0'} + + '@svgdotjs/svg.js@3.2.4': + resolution: {integrity: sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==} + + '@svgdotjs/svg.resize.js@2.0.5': + resolution: {integrity: sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==} + engines: {node: '>= 14.18'} + peerDependencies: + '@svgdotjs/svg.js': ^3.2.4 + '@svgdotjs/svg.select.js': ^4.0.1 + + '@svgdotjs/svg.select.js@4.0.3': + resolution: {integrity: sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==} + engines: {node: '>= 14.18'} + peerDependencies: + '@svgdotjs/svg.js': ^3.2.4 + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -659,6 +705,9 @@ packages: '@types/node@20.17.57': resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==} + '@types/ramda@0.30.2': + resolution: {integrity: sha512-PyzHvjCalm2BRYjAU6nIB3TprYwMNOUY/7P/N8bSzp9W/yM2YrtGtAnnVtaCNSeOZ8DzKyFDvaqQs7LnWwwmBA==} + '@types/react-dom@19.1.5': resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==} peerDependencies: @@ -670,6 +719,9 @@ packages: '@types/stylis@4.2.5': resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -684,6 +736,9 @@ packages: peerDependencies: react: '*' + '@yr/monotone-cubic-spline@1.0.3': + resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} + add-dom-event-listener@1.1.0: resolution: {integrity: sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==} @@ -693,6 +748,9 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' + apexcharts@4.7.0: + resolution: {integrity: sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==} + bcryptjs@3.0.2: resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} hasBin: true @@ -728,16 +786,32 @@ packages: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-convert@3.1.0: + resolution: {integrity: sha512-TVoqAq8ZDIpK5lsQY874DDnu65CSsc9vzq0wLpNQ6UMBq81GSZocVazPiBbYGzngzBOIRahpkTzCLVe2at4MfA==} + engines: {node: '>=14.6'} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.0.0: + resolution: {integrity: sha512-SbtvAMWvASO5TE2QP07jHBMXKafgdZz8Vrsrn96fiL+O92/FN/PLARzUW5sKt013fjAprK2d2iCn2hk2Xb5oow==} + engines: {node: '>=12.20'} + color-string@1.9.1: resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-string@2.0.1: + resolution: {integrity: sha512-5z9FbYTZPAo8iKsNEqRNv+OlpBbDcoE+SY9GjLfDUHEfcNNV7tS9eSAlFHEaub/r5tBL9LtskAeq1l9SaoZ5tQ==} + engines: {node: '>=18'} + color@4.2.3: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + color@5.0.0: + resolution: {integrity: sha512-16BlyiuyLq3MLxpRWyOTiWsO3ii/eLQLJUQXBSNcxMBBSnyt1ee9YUdaozQp03ifwm5woztEZGDbk9RGVuCsdw==} + engines: {node: '>=18'} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -1034,6 +1108,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + ramda@0.30.1: + resolution: {integrity: sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==} + rc-cascader@3.34.0: resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} peerDependencies: @@ -1271,6 +1348,12 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' + react-apexcharts@1.7.0: + resolution: {integrity: sha512-03oScKJyNLRf0Oe+ihJxFZliBQM9vW3UWwomVn4YVRTN1jsIR58dLWt0v1sb8RwJVHDMbeHiKQueM0KGpn7nOA==} + peerDependencies: + apexcharts: '>=4.0.0' + react: '>=0.13' + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -1408,12 +1491,18 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + ts-toolbelt@9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + types-ramda@0.30.1: + resolution: {integrity: sha512-1HTsf5/QVRmLzcGfldPFvkVsAdi1db1BBKzi7iW3KBUlOICg/nKnFS+jGqDJS3YD8VsWbAh7JiHeBvbsw8RPxA==} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -1427,6 +1516,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} @@ -2000,6 +2093,25 @@ snapshots: react-dom: 19.1.0(react@19.1.0) react-is: 18.3.1 + '@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.4)': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + + '@svgdotjs/svg.filter.js@3.0.9': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + + '@svgdotjs/svg.js@3.2.4': {} + + '@svgdotjs/svg.resize.js@2.0.5(@svgdotjs/svg.js@3.2.4)(@svgdotjs/svg.select.js@4.0.3(@svgdotjs/svg.js@3.2.4))': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + '@svgdotjs/svg.select.js': 4.0.3(@svgdotjs/svg.js@3.2.4) + + '@svgdotjs/svg.select.js@4.0.3(@svgdotjs/svg.js@3.2.4)': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': @@ -2091,6 +2203,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/ramda@0.30.2': + dependencies: + types-ramda: 0.30.1 + '@types/react-dom@19.1.5(@types/react@19.1.6)': dependencies: '@types/react': 19.1.6 @@ -2101,6 +2217,8 @@ snapshots: '@types/stylis@4.2.5': {} + '@types/uuid@10.0.0': {} + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@11.0.5': @@ -2113,6 +2231,8 @@ snapshots: dependencies: react: 19.1.0 + '@yr/monotone-cubic-spline@1.0.3': {} + add-dom-event-listener@1.1.0: dependencies: object-assign: 4.1.1 @@ -2175,6 +2295,15 @@ snapshots: - luxon - moment + apexcharts@4.7.0: + dependencies: + '@svgdotjs/svg.draggable.js': 3.0.6(@svgdotjs/svg.js@3.2.4) + '@svgdotjs/svg.filter.js': 3.0.9 + '@svgdotjs/svg.js': 3.2.4 + '@svgdotjs/svg.resize.js': 2.0.5(@svgdotjs/svg.js@3.2.4)(@svgdotjs/svg.select.js@4.0.3(@svgdotjs/svg.js@3.2.4)) + '@svgdotjs/svg.select.js': 4.0.3(@svgdotjs/svg.js@3.2.4) + '@yr/monotone-cubic-spline': 1.0.3 + bcryptjs@3.0.2: {} bson@6.10.4: {} @@ -2200,21 +2329,36 @@ snapshots: color-name: 1.1.4 optional: true + color-convert@3.1.0: + dependencies: + color-name: 2.0.0 + color-name@1.1.4: optional: true + color-name@2.0.0: {} + color-string@1.9.1: dependencies: color-name: 1.1.4 simple-swizzle: 0.2.2 optional: true + color-string@2.0.1: + dependencies: + color-name: 2.0.0 + color@4.2.3: dependencies: color-convert: 2.0.1 color-string: 1.9.1 optional: true + color@5.0.0: + dependencies: + color-convert: 3.1.0 + color-string: 2.0.1 + compute-scroll-into-view@3.1.1: {} copy-to-clipboard@3.3.3: @@ -2475,6 +2619,8 @@ snapshots: punycode@2.3.1: {} + ramda@0.30.1: {} + rc-cascader@3.34.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.4 @@ -2811,6 +2957,12 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + react-apexcharts@1.7.0(apexcharts@4.7.0)(react@19.1.0): + dependencies: + apexcharts: 4.7.0 + prop-types: 15.8.1 + react: 19.1.0 + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -2952,10 +3104,16 @@ snapshots: dependencies: punycode: 2.3.1 + ts-toolbelt@9.6.0: {} + tslib@2.6.2: {} tslib@2.8.1: {} + types-ramda@0.30.1: + dependencies: + ts-toolbelt: 9.6.0 + typescript@5.8.3: {} undici-types@6.19.8: {} @@ -2964,6 +3122,8 @@ snapshots: dependencies: react: 19.1.0 + uuid@11.1.0: {} + warning@4.0.3: dependencies: loose-envify: 1.4.0 diff --git a/src/components/chart/chart.tsx b/src/components/chart/chart.tsx new file mode 100644 index 0000000..57957c5 --- /dev/null +++ b/src/components/chart/chart.tsx @@ -0,0 +1,30 @@ +/** + * 文件: chart/chart.tsx + * 作者: 阿瑞 + * 功能: ApexChart 图表组件封装 - 支持 SSR + * 版本: v1.0.0 + */ +import dynamic from 'next/dynamic'; +import { memo } from 'react'; +import type { Props as ApexChartProps } from 'react-apexcharts'; +//import { StyledApexChart } from './styles'; + +//import { useSettings } from '@/store/settingStore'; +//import { useThemeToken } from '@/theme/hooks'; + +// 使用 next/dynamic 动态导入 ApexChart,并禁用 SSR +const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); +function Chart(props: ApexChartProps) { + // 检查是否在客户端环境 + if (typeof window === 'undefined') { + return null; // 如果是在服务器端渲染,则返回 null,不加载图表 + } + //const { themeMode } = useSettings(); + //const theme = useThemeToken(); + return ( + + + + ); +} +export default memo(Chart); diff --git a/src/components/chart/styles.ts b/src/components/chart/styles.ts new file mode 100644 index 0000000..ff23343 --- /dev/null +++ b/src/components/chart/styles.ts @@ -0,0 +1,59 @@ +//src\components\chart\styles.ts +import { GlobalToken } from 'antd'; +import Color from 'color'; +import styled from 'styled-components'; + +import { ThemeMode } from '@/types/enum'; + +export const StyledApexChart = styled.div<{ $thememode: ThemeMode; $theme: GlobalToken }>` + .apexcharts-canvas { + /* TOOLTIP */ + .apexcharts-tooltip { + color: ${(props) => props.$theme.colorText}; + border-radius: 10px; + backdrop-filter: blur(6px); + background-color: ${(props) => Color(props.$theme.colorBgElevated).alpha(0.8).toString()}; + box-shadow: ${(props) => + props.$thememode === ThemeMode.Light + ? `rgba(145, 158, 171, 0.24) 0px 0px 2px 0px, rgba(145, 158, 171, 0.24) -20px 20px 40px -4px` + : `rgba(0, 0, 0, 0.24) 0px 0px 2px 0px, rgba(0, 0, 0, 0.24) -20px 20px 40px -4px;`}; + .apexcharts-tooltip-title { + text-align: center; + font-weight: bold; + background-color: rgba(145, 158, 171, 0.08); + } + } + + /* TOOLTIP X */ + .apexcharts-xaxistooltip { + color: ${(props) => props.$theme.colorText}; + border-radius: 10px; + backdrop-filter: blur(6px); + border-color: transparent; + box-shadow: ${(props) => + props.$thememode === ThemeMode.Light + ? `rgba(145, 158, 171, 0.24) 0px 0px 2px 0px, rgba(145, 158, 171, 0.24) -20px 20px 40px -4px` + : `rgba(0, 0, 0, 0.24) 0px 0px 2px 0px, rgba(0, 0, 0, 0.24) -20px 20px 40px -4px;`}; + background-color: ${(props) => Color(props.$theme.colorBgElevated).alpha(0.8).toString()}; + &::before { + border-bottom-color: rgba(145, 158, 171, 0.24); + } + &::after { + border-bottom-color: rgba(255, 255, 255, 0.8); + } + } + + /* LEGEND */ + .apexcharts-legend { + padding: 0; + .apexcharts-legend-series { + display: inline-flex !important; + align-items: ecnter; + } + .apexcharts-legend-text { + line-height: 18px; + text-transform: capitalize; + } + } + } +`; diff --git a/src/components/chart/useChart.ts b/src/components/chart/useChart.ts new file mode 100644 index 0000000..58fc4d2 --- /dev/null +++ b/src/components/chart/useChart.ts @@ -0,0 +1,210 @@ +/** + * 文件: chart/useChart.ts + * 作者: 阿瑞 + * 功能: ApexCharts 主题配置 Hook + * 版本: v2.0.0 - 修复 token 属性兼容性 + */ +import { ApexOptions } from 'apexcharts'; +import { mergeDeepRight } from 'ramda'; +import { useThemeToken } from '@/theme/hooks'; + +export default function useChart(options: ApexOptions) { + const theme = useThemeToken(); + + const LABEL_TOTAL = { + show: true, + label: 'Total', + color: theme.colorTextSecondary, + fontSize: `${theme.fontSizeLG || theme.fontSize || 16}px`, + fontWeight: 'bold', + }; + + const LABEL_VALUE = { + offsetY: 8, + color: theme.colorText, + fontSize: `${theme.fontSizeLG || theme.fontSize || 14}px`, + }; + + const baseOptions: ApexOptions = { + // Colors + colors: [ + theme.colorPrimary, + theme.colorWarning || '#ff9640', + theme.colorInfo || '#8e6bff', + theme.colorError || '#ff4d4f', + theme.colorSuccess || '#06d7b2', + theme.colorWarningActive || theme.colorWarning || '#ff9640', + theme.colorSuccessActive || theme.colorSuccess || '#06d7b2', + theme.colorInfoActive || theme.colorInfo || '#8e6bff', + theme.colorInfoText || theme.colorInfo || '#8e6bff', + ], + + // Chart + chart: { + toolbar: { show: false }, + zoom: { enabled: false }, + foreColor: theme.colorTextTertiary || theme.colorTextSecondary, + fontFamily: theme.fontFamily, + }, + + // States + states: { + hover: { + filter: { + type: 'lighten', + value: 0.04, + } as any, + }, + active: { + filter: { + type: 'darken', + value: 0.88, + } as any, + }, + }, + + // Fill + fill: { + opacity: 1, + gradient: { + type: 'vertical', + shadeIntensity: 0, + opacityFrom: 0.4, + opacityTo: 0, + stops: [0, 100], + }, + }, + + // Datalabels + dataLabels: { + enabled: false, + }, + + // Stroke + stroke: { + width: 3, + curve: 'smooth', + lineCap: 'round', + }, + + // Grid + grid: { + strokeDashArray: 3, + borderColor: theme.colorBorderSecondary || theme.colorBorder, + xaxis: { + lines: { + show: false, + }, + }, + }, + + // Xaxis + xaxis: { + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + + // Markers + markers: { + size: 0, + }, + + // Tooltip + tooltip: { + theme: 'light', + x: { + show: true, + }, + }, + + // Legend + legend: { + show: true, + fontSize: '13px', + position: 'top', + horizontalAlign: 'right', + markers: { + size: 12, + }, + fontWeight: 500, + itemMargin: { + horizontal: 8, + }, + labels: { + colors: theme.colorText, + }, + }, + + // plotOptions + plotOptions: { + // Bar + bar: { + borderRadius: 4, + columnWidth: '28%', + borderRadiusApplication: 'end', + borderRadiusWhenStacked: 'last', + }, + + // Pie + Donut + pie: { + donut: { + labels: { + show: true, + value: LABEL_VALUE, + total: LABEL_TOTAL, + }, + }, + }, + + // Radialbar + radialBar: { + track: { + strokeWidth: '100%', + }, + dataLabels: { + value: LABEL_VALUE, + total: LABEL_TOTAL, + }, + }, + + // Radar + radar: { + polygons: { + fill: { colors: ['transparent'] }, + strokeColors: theme.colorBorderSecondary || theme.colorBorder, + connectorColors: theme.colorBorderSecondary || theme.colorBorder, + }, + }, + + // polarArea + polarArea: { + rings: { + strokeColor: theme.colorBorderSecondary || theme.colorBorder, + }, + spokes: { + connectorColors: theme.colorBorderSecondary || theme.colorBorder, + }, + }, + }, + + // Responsive + responsive: [ + { + // sm - 假设屏幕断点为 576px + breakpoint: 576, + options: { + plotOptions: { bar: { columnWidth: '40%' } }, + }, + }, + { + // md - 假设屏幕断点为 768px + breakpoint: 768, + options: { + plotOptions: { bar: { columnWidth: '32%' } }, + }, + }, + ], + }; + + return mergeDeepRight(baseOptions, options) as ApexOptions; +} diff --git a/src/pages/api/backstage/mine/info/[id].ts b/src/pages/api/backstage/mine/info/[id].ts new file mode 100644 index 0000000..b569a19 --- /dev/null +++ b/src/pages/api/backstage/mine/info/[id].ts @@ -0,0 +1,90 @@ +// src/pages/api/backstage/mine/info/[id].ts + +import { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { User } from '@/models'; +import { IPermission, IUser } from '@/models/types'; +import { buildPermissionTree } from '@/pages/api/buildPermissionTree'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query; + + if (!id) { + return res.status(400).json({ error: '缺少用户ID' }); + } + + switch (req.method) { + case 'GET': + try { + const user = await User.findById(id) + //关联团队 + .populate({ + path: '团队', + select: '名称 拥有者', + populate: [ + { path: '拥有者', select: '姓名' }, + ] + }) + //关联角色 + .populate({ + path: '角色', + select: '名称 描述 权限', + populate: { + path: '权限', + populate: { path: '子级' }, + }, + }) + .select('-密码'); + if (!user) { + return res.status(404).json({ error: '用户不存在' }); + } + + + // 构建权限树 + const permissionsTree = buildPermissionTree( + user.角色.权限.map((perm: IPermission) => perm as IPermission), + ); + + const userInfo = { + _id: user._id, + 邮箱: user.邮箱, + 姓名: user.姓名, + 团队: user.团队, // 这将是一个完整的团队对象 + 角色: { + ...user.角色.toObject(), // 将 Mongoose 文档转换为普通对象 + 权限: permissionsTree, // 使用构建好的权限树替换原始权限列表 + }, + 微信昵称: user.微信昵称, + 头像: user.头像, + 电话: user.电话, + unionid: user.unionid, + openid: user.openid, + }; + + res.status(200).json(userInfo ); + } catch (error) { + res.status(500).json({ error: '服务器错误' }); + } + break; + + case 'PUT': + try { + const updateData = req.body as Partial; + + const user = await User.findByIdAndUpdate(id, updateData, { new: true }).select('-密码'); + if (!user) { + return res.status(404).json({ error: '用户不存在' }); + } + res.status(200).json(user); + } catch (error) { + res.status(500).json({ error: '服务器错误' }); + } + break; + + default: + res.setHeader('Allow', ['GET', 'PUT']); + res.status(405).end(`方法 ${req.method} 不被允许`); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/backstage/mine/sales/index.ts b/src/pages/api/backstage/mine/sales/index.ts new file mode 100644 index 0000000..7d57c12 --- /dev/null +++ b/src/pages/api/backstage/mine/sales/index.ts @@ -0,0 +1,33 @@ +// src/pages/api/backstage/mine/sales/index.ts + +import { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { SalesRecord } from '@/models'; +//import { ISalesRecord } from '@/models/types'; + +// 假设您有一个身份验证中间件来获取当前用户的ID,例如从 req.userId 获取 +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + // 获取当前用户ID,您需要根据实际情况进行调整 + const userId = (req as any).userId as string; + + if (!userId) { + return res.status(401).json({ error: '未授权的访问' }); + } + + switch (req.method) { + case 'GET': + try { + const salesRecords = await SalesRecord.find({ 导购: userId }); + res.status(200).json(salesRecords); + } catch (error) { + res.status(500).json({ error: '服务器错误' }); + } + break; + + default: + res.setHeader('Allow', ['GET']); + res.status(405).end(`方法 ${req.method} 不被允许`); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/backstage/sales/aftersale/index.ts b/src/pages/api/backstage/sales/aftersale/index.ts new file mode 100644 index 0000000..6850b3a --- /dev/null +++ b/src/pages/api/backstage/sales/aftersale/index.ts @@ -0,0 +1,48 @@ +// src/pages/api/backstage/sales/aftersale/index.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { AfterSalesRecord, SalesRecord } from '@/models'; // 售后记录模型文件名为 AfterSalesRecord +import connectDB from '@/utils/connectDB'; +import mongoose from 'mongoose'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'POST') { + try { + // 防御性检查,确保原产品和替换产品字段存在并且是数组 + const originalProductIds = Array.isArray(req.body.原产品) + ? req.body.原产品.map((id: string) => new mongoose.Types.ObjectId(id)) + : []; + const replacementProductIds = Array.isArray(req.body.替换产品) + ? req.body.替换产品.map((id: string) => new mongoose.Types.ObjectId(id)) + : []; + // 直接使用 req.body 传递的所有字段 + const newAfterSalesRecord = new AfterSalesRecord({ + ...req.body, + 原产品: originalProductIds, // 保存原产品的 ObjectId 数组 + 替换产品: replacementProductIds, // 保存替换产品的 ObjectId 数组 + 日期: new Date(req.body.日期), // 确保日期字段是 Date 类型 + 售后进度: '待处理', // 默认设置为 "待处理" + }); + // 保存售后记录到数据库 + //await newAfterSalesRecord.save(); + + // 保存售后记录到数据库 + const savedAfterSalesRecord = await newAfterSalesRecord.save(); + + // 更新对应的销售记录,将新创建的售后记录的ID添加到 "售后记录" 数组中 + await SalesRecord.findByIdAndUpdate(req.body.销售记录, { + $push: { 售后记录: savedAfterSalesRecord._id } + }); + + // 返回成功响应 + res.status(201).json({ message: '售后记录创建成功', record: newAfterSalesRecord }); + } catch (error) { + console.error('售后记录创建失败:', error); + res.status(500).json({ message: '售后记录创建失败' }); + } + } else { + res.setHeader('Allow', ['POST']); + res.status(405).end(`Method ${req.method} Not Allowed`); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/backstage/sales/aftersale/records/[id].ts b/src/pages/api/backstage/sales/aftersale/records/[id].ts new file mode 100644 index 0000000..21f31ca --- /dev/null +++ b/src/pages/api/backstage/sales/aftersale/records/[id].ts @@ -0,0 +1,81 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; // 确保数据库连接 +import { AfterSalesRecord } from '@/models'; // 导入售后记录模型 +//import { IAfterSalesRecord } from '@/models/types'; // 导入售后记录类型定义 +import { isValidObjectId } from 'mongoose'; // Mongoose 工具函数 + +// 定义 API 处理函数 +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req; + const { id } = req.query; + + if (!isValidObjectId(id)) { + return res.status(400).json({ message: '无效的售后记录 ID' }); + } + + switch (method) { + case 'PATCH': + try { + const updateData = req.body; // 接收前端传入的字段 + console.log('接收到的更新数据:', JSON.stringify(updateData, null, 2)); + + // 校验:根据不同的售后类型有不同的字段要求 + // 对于换货类型,放宽限制,只需要类型字段 + if (!updateData.类型) { + return res.status(400).json({ message: '类型是必须的' }); + } + + // 准备更新字段 + const updateFields = { ...updateData }; + + // 特殊处理替换产品字段,确保它是一个ID数组 + if (updateData.替换产品) { + // 确保替换产品是一个数组 + const productIds = Array.isArray(updateData.替换产品) + ? updateData.替换产品 + : [updateData.替换产品]; + + // 验证每个ID是否有效 + const validProductIds = productIds.filter((id: string) => isValidObjectId(id)); + updateFields.替换产品 = validProductIds; + } + + // 特殊处理收支平台字段 + if (updateData.收支平台 === null) { + // 如果前端明确传入null,则设置为null + updateFields.收支平台 = null; + } else if (updateData.收支平台 && updateData.收支平台._id) { + // 如果是有效对象,则提取ID + updateFields.收支平台 = updateData.收支平台._id; + } + + console.log('处理后的更新字段:', JSON.stringify(updateFields, null, 2)); + + // 使用 Mongoose 的 $set 操作动态更新传入的字段 + const updatedRecord = await AfterSalesRecord.findByIdAndUpdate( + id, + { $set: updateFields, updatedAt: new Date() }, + { new: true } // 返回更新后的记录 + ).populate('替换产品'); // 确保返回替换产品的完整信息 + + if (!updatedRecord) { + return res.status(404).json({ message: '售后记录未找到' }); + } + + res.status(200).json({ + message: '售后记录更新成功', + data: updatedRecord, + }); + } catch (error) { + console.error('更新售后记录时出错:', error); + res.status(500).json({ message: '服务器错误,更新售后记录失败' }); + } + break; + + default: + res.setHeader('Allow', ['PATCH']); + res.status(405).end(`Method ${method} Not Allowed`); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/backstage/sales/aftersale/records/index.ts b/src/pages/api/backstage/sales/aftersale/records/index.ts new file mode 100644 index 0000000..4d0a378 --- /dev/null +++ b/src/pages/api/backstage/sales/aftersale/records/index.ts @@ -0,0 +1,84 @@ +//src/pages/api/backstage/sales/aftersale/records/index.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { AfterSalesRecord } from '@/models'; // 导入售后记录模型 +import connectDB from '@/utils/connectDB'; // 确保数据库已连接 +import mongoose from 'mongoose'; + +interface IQuery { + teamId?: string; + salesRecordId?: string; +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'GET') { + try { + const { teamId, salesRecordId } = req.query as IQuery; + + const query: any = {}; + + if (teamId) { + query.团队 = new mongoose.Types.ObjectId(teamId); + } + + if (salesRecordId) { + query.销售记录 = new mongoose.Types.ObjectId(salesRecordId); + } + + const afterSalesRecords = await AfterSalesRecord.find(query) + .populate('团队') + //.populate('销售记录') + .populate({ + path: '销售记录', + select: '订单来源 客户 导购 成交日期 订单状态 收款状态 收款平台 应收金额 收款金额 待收款', + populate: [ + { path: '客户', select: '姓名 电话 地址' }, + { path: '导购', select: '姓名' }, + { path: '订单来源', select: '账号编号' }, + { path: '收款平台', select: '名称' }, + ] + }) + //原产品和替换产品排除图片字段 + .populate({ + path: '原产品', + select: '名称 售价 成本 品类 品牌 供应商', // 选择需要的产品字段并排除图片 + populate: [ + { path: '品类', select: 'name' }, // 填充品类,选择名称字段 + { path: '品牌', select: 'name' }, // 填充品牌,选择名称字段 + { path: '供应商', select: '供应商名称 联系方式' }, // 填充供应商,选择名称和联系方式字段 + ] + }) + .populate({ + path: '替换产品', + select: '名称 售价 成本 品类 品牌 供应商', // 选择需要的产品字段并排除图片 + populate: [ + { path: '品类', select: 'name' }, // 填充品类,选择名称字段 + { path: '品牌', select: 'name' }, // 填充品牌,选择名称字段 + { path: '供应商', select: '供应商名称 联系方式' }, // 填充供应商,选择名称和联系方式字段 + ] + }) + + //.populate('收支平台') + .populate({ + path: '收支平台', + select: '名称', + }) + .populate({ + path: '收款码', + select: '_id', + }) + //按时间倒序排列 + .sort({ createdAt: -1 }) + .exec(); + + res.status(200).json({ records: afterSalesRecords }); + } catch (error) { + console.error('获取售后记录失败:', error); + res.status(500).json({ message: '获取售后记录失败' }); + } + } else { + res.setHeader('Allow', ['GET']); + res.status(405).end(`Method ${req.method} Not Allowed`); + } +}; + +export default connectDB(handler); diff --git a/src/pages/api/backstage/sales/aftersale/records/updateProgress.ts b/src/pages/api/backstage/sales/aftersale/records/updateProgress.ts new file mode 100644 index 0000000..1d99124 --- /dev/null +++ b/src/pages/api/backstage/sales/aftersale/records/updateProgress.ts @@ -0,0 +1,71 @@ +//src\pages\api\backstage\sales\aftersale\records\updateProgress.ts +import { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { AfterSalesRecord, SalesRecord } from '@/models'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req; + + switch (method) { + case 'PATCH': + try { + const { id, progress } = req.body; + + if (!id || !progress) { + return res.status(400).json({ message: '缺少必要的参数' }); + } + + // 查找并更新售后进度 + const record = await AfterSalesRecord.findByIdAndUpdate( + id, + { 售后进度: progress }, + { new: true } + ).populate('销售记录'); + + if (!record) { + return res.status(404).json({ message: '未找到对应的售后记录' }); + } + + // 确保销售记录存在 + if (!record.销售记录 || !record.销售记录._id) { + return res.status(404).json({ message: '未找到对应的销售记录' }); + } + + // 更新销售记录的订单状态 + let orderStatusUpdate = ''; + if (progress === '处理中') { + if (record.类型 === '退货') orderStatusUpdate = '退货中'; + if (record.类型 === '换货') orderStatusUpdate = '换货中'; + if (record.类型 === '补发') orderStatusUpdate = '补发中'; + } else if (progress === '已处理') { + if (record.类型 === '退货') orderStatusUpdate = '已退款'; + if (record.类型 === '换货') orderStatusUpdate = '换货完成'; + if (record.类型 === '补发') orderStatusUpdate = '已补发'; + } else if (progress === '待处理') { + if (record.类型 === '退货') orderStatusUpdate = '退货中'; + if (record.类型 === '换货') orderStatusUpdate = '换货中'; + if (record.类型 === '补发') orderStatusUpdate = '补发中'; + } + + if (orderStatusUpdate) { + await SalesRecord.findByIdAndUpdate( + record.销售记录._id, + { 订单状态: orderStatusUpdate }, + { new: true } + ); + } + + res.status(200).json({ message: '售后进度和订单状态更新成功', data: record }); + } catch (error) { + console.error('售后进度更新失败:', error); + res.status(500).json({ message: '服务器错误', error }); + } + break; + default: + res.setHeader('Allow', ['PATCH']); + res.status(405).json({ message: `不支持 ${method} 请求` }); + break; + } +} + +export default connectDB(handler); diff --git a/src/pages/api/backstage/sales/aftersale/uploadPaymentCode.ts b/src/pages/api/backstage/sales/aftersale/uploadPaymentCode.ts new file mode 100644 index 0000000..71d1543 --- /dev/null +++ b/src/pages/api/backstage/sales/aftersale/uploadPaymentCode.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import connectDB from '@/utils/connectDB'; +import { CustomerPaymentCode } from '@/models'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'POST') { + const { 收款码 } = req.body; + + try { + const paymentCode = new CustomerPaymentCode({ 收款码 }); + await paymentCode.save(); + + return res.status(200).json({ message: '收款码上传成功', paymentCodeId: paymentCode._id }); + } catch (error) { + return res.status(500).json({ message: 'Internal server error', error }); + } + } else { + return res.status(405).json({ message: 'Method not allowed' }); + } +}; + +export default connectDB(handler); diff --git a/src/pages/components/PaymentCodeImage.tsx b/src/pages/components/PaymentCodeImage.tsx new file mode 100644 index 0000000..441560e --- /dev/null +++ b/src/pages/components/PaymentCodeImage.tsx @@ -0,0 +1,65 @@ +//src\pages\components\PaymentCodeImage.tsx +import React, { useEffect, useState } from 'react'; +import { Spin, Image } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; + +interface PaymentCodeImageProps { + paymentCodeId?: string; + width?: number | string; + height?: number | string; +} + +const PaymentCodeImageComponent: React.FC = ({ paymentCodeId, width = '100%', height = 'auto' }) => { + const [imageSrc, setImageSrc] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchImage = async () => { + setIsLoading(true); + try { + const response = await fetch(`/api/paymentCodeImage/${paymentCodeId}`); + if (response.ok) { + const imageBase64 = await response.text(); + setImageSrc(imageBase64); + } else if (response.status === 404) { + setImageSrc(null); + } else { + throw new Error('Network response was not ok'); + } + } catch (error) { + console.error('Failed to fetch payment code image', error); + setImageSrc(null); + } finally { + setIsLoading(false); + } + }; + + if (paymentCodeId) { + fetchImage(); + } + }, [paymentCodeId]); + + const antIcon = ; + const fallbackImage = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 24 24' fill='none' stroke='%23adb5bd' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='2' ry='2'/%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'/%3E%3Cpolyline points='21 15 16 10 5 21'/%3E%3C/svg%3E"; + + return ( + isLoading ? ( +
+ +
+ ) : imageSrc ? ( + Payment Code Image + ) : ( +
暂无图片
+ ) + ); +}; + +export default PaymentCodeImageComponent; diff --git a/src/pages/components/chart/index.tsx b/src/pages/components/chart/index.tsx new file mode 100644 index 0000000..25f5851 --- /dev/null +++ b/src/pages/components/chart/index.tsx @@ -0,0 +1,88 @@ +import { Card, Col, Row } from 'antd'; +import ChartArea from './view/chart-area'; +import ChartBar from './view/chart-bar'; +import ChartColumnMultiple from './view/chart-column-multiple'; +import ChartColumnNegative from './view/chart-column-negative'; +import ChartColumnSingle from './view/chart-column-single'; +import ChartColumnStacked from './view/chart-column-Stacked'; +import ChartDonut from './view/chart-donut'; +import ChartLine from './view/chart-line'; +import ChartMixed from './view/chart-mixed'; +import ChartPie from './view/chart-pie'; +import ChartRadar from './view/chart-radar'; +import ChartRadial from './view/chart-radial'; + +export default function ChartPage() { + //const { colorPrimary } = useThemeToken(); + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/components/chart/view/chart-area.tsx b/src/pages/components/chart/view/chart-area.tsx new file mode 100644 index 0000000..c759eaf --- /dev/null +++ b/src/pages/components/chart/view/chart-area.tsx @@ -0,0 +1,30 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [ + { name: 'series1', data: [31, 40, 28, 51, 42, 109, 100] }, + { name: 'series2', data: [11, 32, 45, 32, 34, 52, 41] }, +]; +export default function ChartArea() { + const chartOptions = useChart({ + xaxis: { + type: 'datetime', + categories: [ + '2018-09-19T00:00:00.000Z', + '2018-09-19T01:30:00.000Z', + '2018-09-19T02:30:00.000Z', + '2018-09-19T03:30:00.000Z', + '2018-09-19T04:30:00.000Z', + '2018-09-19T05:30:00.000Z', + '2018-09-19T06:30:00.000Z', + ], + }, + tooltip: { + x: { + format: 'dd/MM/yy HH:mm', + }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-bar.tsx b/src/pages/components/chart/view/chart-bar.tsx new file mode 100644 index 0000000..1984107 --- /dev/null +++ b/src/pages/components/chart/view/chart-bar.tsx @@ -0,0 +1,29 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [400, 430, 448, 470, 540, 580, 690, 1100, 1200, 1380]; + +export default function ChartBar() { + const chartOptions = useChart({ + stroke: { show: false }, + plotOptions: { + bar: { horizontal: true, barHeight: '30%' }, + }, + xaxis: { + categories: [ + 'Italy', + 'Japan', + 'China', + 'Canada', + 'France', + 'Germany', + 'South Korea', + 'Netherlands', + 'United States', + 'United Kingdom', + ], + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-column-Stacked.tsx b/src/pages/components/chart/view/chart-column-Stacked.tsx new file mode 100644 index 0000000..c3ab8ad --- /dev/null +++ b/src/pages/components/chart/view/chart-column-Stacked.tsx @@ -0,0 +1,47 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [ + { name: 'Product A', data: [44, 55, 41, 67, 22, 43] }, + { name: 'Product B', data: [13, 23, 20, 8, 13, 27] }, + { name: 'Product C', data: [11, 17, 15, 15, 21, 14] }, + { name: 'Product D', data: [21, 7, 25, 13, 22, 8] }, +]; +export default function ChartColumnStacked() { + const chartOptions = useChart({ + chart: { + stacked: true, + zoom: { + enabled: true, + }, + }, + legend: { + itemMargin: { + vertical: 8, + }, + position: 'right', + offsetY: 20, + }, + plotOptions: { + bar: { + columnWidth: '16%', + }, + }, + stroke: { + show: false, + }, + xaxis: { + type: 'datetime', + categories: [ + '01/01/2011 GMT', + '01/02/2011 GMT', + '01/03/2011 GMT', + '01/04/2011 GMT', + '01/05/2011 GMT', + '01/06/2011 GMT', + ], + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-column-multiple.tsx b/src/pages/components/chart/view/chart-column-multiple.tsx new file mode 100644 index 0000000..01fe89a --- /dev/null +++ b/src/pages/components/chart/view/chart-column-multiple.tsx @@ -0,0 +1,33 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [ + { + name: 'Net Profit', + data: [44, 55, 57, 56, 61, 58, 63, 60, 66], + }, + { + name: 'Revenue', + data: [76, 85, 101, 98, 87, 105, 91, 114, 94], + }, +]; +export default function ChartColumnMultiple() { + const chartOptions = useChart({ + stroke: { + show: true, + width: 2, + colors: ['transparent'], + }, + xaxis: { + categories: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'], + }, + tooltip: { + y: { + formatter: (value: number) => `$ ${value} thousands`, + }, + }, + plotOptions: { bar: { columnWidth: '36%' } }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-column-negative.tsx b/src/pages/components/chart/view/chart-column-negative.tsx new file mode 100644 index 0000000..6917ac1 --- /dev/null +++ b/src/pages/components/chart/view/chart-column-negative.tsx @@ -0,0 +1,80 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; +//import { useThemeToken } from '@/theme/hooks'; + +const series = [ + { + name: 'Cash Flow', + data: [ + 1.45, 5.42, 5.9, -0.42, -12.6, -18.1, -18.2, -14.16, -11.1, -6.09, 0.34, 3.88, 13.07, 5.8, 2, + 7.37, 8.1, 13.57, 15.75, 17.1, 19.8, -27.03, -54.4, -47.2, -43.3, -18.6, -48.6, -41.1, -39.6, + -37.6, -29.4, -21.4, -2.4, + ], + }, +]; +export default function ChartColumnNegative() { + //const theme = useThemeToken(); + const chartOptions = useChart({ + stroke: { show: false }, + yaxis: { + labels: { + formatter: (value: number) => `${value.toFixed(0)}%`, + }, + }, + xaxis: { + type: 'datetime', + categories: [ + '2011-01-01', + '2011-02-01', + '2011-03-01', + '2011-04-01', + '2011-05-01', + '2011-06-01', + '2011-07-01', + '2011-08-01', + '2011-09-01', + '2011-10-01', + '2011-11-01', + '2011-12-01', + '2012-01-01', + '2012-02-01', + '2012-03-01', + '2012-04-01', + '2012-05-01', + '2012-06-01', + '2012-07-01', + '2012-08-01', + '2012-09-01', + '2012-10-01', + '2012-11-01', + '2012-12-01', + '2013-01-01', + '2013-02-01', + '2013-03-01', + '2013-04-01', + '2013-05-01', + '2013-06-01', + '2013-07-01', + '2013-08-01', + '2013-09-01', + ], + }, + plotOptions: { + bar: { + columnWidth: '58%', + colors: { + ranges: [ + { from: -100, to: -46, + //color: theme.colorWarning + }, + { from: -45, to: 0, + //color: theme.colorInfo + }, + ], + }, + }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-column-single.tsx b/src/pages/components/chart/view/chart-column-single.tsx new file mode 100644 index 0000000..bd71d4e --- /dev/null +++ b/src/pages/components/chart/view/chart-column-single.tsx @@ -0,0 +1,26 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [{ name: 'Net Profit', data: [44, 55, 57, 56, 61, 58, 63, 60, 66] }]; +export default function ChartColumnSingle() { + const chartOptions = useChart({ + plotOptions: { + bar: { + columnWidth: '16%', + }, + }, + stroke: { + show: false, + }, + xaxis: { + categories: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'], + }, + tooltip: { + y: { + formatter: (value: number) => `$ ${value} thousands`, + }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-donut.tsx b/src/pages/components/chart/view/chart-donut.tsx new file mode 100644 index 0000000..aad488a --- /dev/null +++ b/src/pages/components/chart/view/chart-donut.tsx @@ -0,0 +1,27 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [44, 55, 13, 43]; +export default function ChartDonut() { + const chartOptions = useChart({ + labels: ['Apple', 'Mango', 'Orange', 'Watermelon'], + stroke: { + show: false, + }, + legend: { + horizontalAlign: 'center', + }, + tooltip: { + fillSeriesColor: false, + }, + plotOptions: { + pie: { + donut: { + size: '90%', + }, + }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-line.tsx b/src/pages/components/chart/view/chart-line.tsx new file mode 100644 index 0000000..1e1994a --- /dev/null +++ b/src/pages/components/chart/view/chart-line.tsx @@ -0,0 +1,24 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [ + { + name: 'Desktops', + data: [10, 41, 35, 51, 49, 62, 69, 91, 148], + }, +]; +export default function ChartLine() { + const chartOptions = useChart({ + xaxis: { + categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'], + }, + tooltip: { + x: { + show: false, + }, + marker: { show: false }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-mixed.tsx b/src/pages/components/chart/view/chart-mixed.tsx new file mode 100644 index 0000000..6d925ae --- /dev/null +++ b/src/pages/components/chart/view/chart-mixed.tsx @@ -0,0 +1,68 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [ + { + name: 'Team A', + type: 'column', + data: [23, 11, 22, 27, 13, 22, 37, 21, 44, 22, 30], + }, + { + name: 'Team B', + type: 'area', + data: [44, 55, 41, 67, 22, 43, 21, 41, 56, 27, 43], + }, + { + name: 'Team C', + type: 'line', + data: [30, 25, 36, 30, 45, 35, 64, 52, 59, 36, 39], + }, +]; + +export default function ChartMixed() { + const chartOptions = useChart({ + stroke: { + width: [0, 2, 3], + }, + plotOptions: { + bar: { columnWidth: '20%' }, + }, + fill: { + type: ['solid', 'gradient', 'solid'], + }, + labels: [ + '01/01/2003', + '02/01/2003', + '03/01/2003', + '04/01/2003', + '05/01/2003', + '06/01/2003', + '07/01/2003', + '08/01/2003', + '09/01/2003', + '10/01/2003', + '11/01/2003', + ], + xaxis: { + type: 'datetime', + }, + yaxis: { + title: { text: 'Points' }, + min: 0, + }, + tooltip: { + shared: true, + intersect: false, + y: { + formatter: (value: number) => { + if (typeof value !== 'undefined') { + return `${value.toFixed(0)} points`; + } + return value; + }, + }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-pie.tsx b/src/pages/components/chart/view/chart-pie.tsx new file mode 100644 index 0000000..ab8a7ac --- /dev/null +++ b/src/pages/components/chart/view/chart-pie.tsx @@ -0,0 +1,35 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; + +const series = [44, 55, 13, 43]; +export default function ChartPie() { + const chartOptions = useChart({ + labels: ['America', 'Asia', 'Europe', 'Africa'], + legend: { + horizontalAlign: 'center', + }, + stroke: { + show: false, + }, + dataLabels: { + enabled: true, + dropShadow: { + enabled: false, + }, + }, + tooltip: { + fillSeriesColor: false, + }, + plotOptions: { + pie: { + donut: { + labels: { + show: false, + }, + }, + }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-radar.tsx b/src/pages/components/chart/view/chart-radar.tsx new file mode 100644 index 0000000..c89f951 --- /dev/null +++ b/src/pages/components/chart/view/chart-radar.tsx @@ -0,0 +1,44 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; +//import { useThemeToken } from '@/theme/hooks'; + +const series = [ + { + name: 'Series 1', + data: [80, 50, 30, 40, 100, 20], + }, + { + name: 'Series 2', + data: [20, 30, 40, 80, 20, 80], + }, + { + name: 'Series 3', + data: [44, 76, 78, 13, 43, 10], + }, +]; +export default function ChartRadar() { + //const { colorText } = useThemeToken(); + const chartOptions = useChart({ + stroke: { + width: 2, + }, + fill: { + opacity: 0.48, + }, + legend: { + floating: true, + position: 'bottom', + horizontalAlign: 'center', + }, + xaxis: { + categories: ['2011', '2012', '2013', '2014', '2015', '2016'], + labels: { + style: { + colors: '#9aa0ac', + }, + }, + }, + }); + + return ; +} diff --git a/src/pages/components/chart/view/chart-radial.tsx b/src/pages/components/chart/view/chart-radial.tsx new file mode 100644 index 0000000..fc02458 --- /dev/null +++ b/src/pages/components/chart/view/chart-radial.tsx @@ -0,0 +1,37 @@ +import Chart from '@/components/chart/chart'; +import useChart from '@/components/chart/useChart'; +//import { fNumber } from '@/utils/format-number'; + +const series = [44, 55]; +export default function ChartRadial() { + const chartOptions = useChart({ + chart: { + sparkline: { + enabled: true, + }, + }, + labels: ['Apples', 'Oranges'], + legend: { + floating: true, + position: 'bottom', + horizontalAlign: 'center', + }, + plotOptions: { + radialBar: { + hollow: { + size: '68%', + }, + dataLabels: { + value: { + offsetY: 16, + }, + total: { + //formatter: () => fNumber(2324), + }, + }, + }, + }, + }); + + return ; +} diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index b2b5735..e8bb7bb 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -78,7 +78,7 @@ const UserInfoCard: React.FC = ({ userInfo, loading = false }
= ({ userInfo }) => { if (!team) { return ( - + ); @@ -174,7 +174,7 @@ const TeamInfoCard: React.FC = ({ userInfo }) => { } className={styles.teamInfoCard} - bordered={false} + variant="outlined" >
@@ -221,7 +221,7 @@ const RolePermissionCard: React.FC = ({ userInfo }) => if (!role) { return ( - + ); @@ -236,7 +236,7 @@ const RolePermissionCard: React.FC = ({ userInfo }) => } className={styles.rolePermissionCard} - bordered={false} + variant="outlined" >
diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 4a08dae..932e69b 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -38,8 +38,13 @@ import { GlobalOutlined, SafetyOutlined, LineChartOutlined, + DashboardOutlined, + UserOutlined, + UsergroupAddOutlined, } from '@ant-design/icons'; +import Link from 'next/link'; import { useTheme } from '@/hooks/useTheme'; // 关键代码行注释:使用独立的useTheme Hook +import { useUserInfo, useUserHomePath } from '@/store/userStore'; // 关键代码行注释:用户状态管理 import { MdDarkMode, MdLightMode } from "react-icons/md"; import styles from './index.module.css'; // 关键代码行注释:导入CSS模块样式 @@ -150,6 +155,10 @@ export default function HomePage(): React.ReactElement { // 关键代码行注释:状态管理 - 使用_app.tsx的useTheme Hook确保mounted状态 const { isDark, toggleTheme, mounted, isTransitioning } = useTheme(); // 关键代码行注释:解构获取所需的状态 const { message, notification, modal } = App.useApp(); + + // 关键代码行注释:用户登录状态管理 + const userInfo = useUserInfo(); // 关键代码行注释:获取用户信息 + const homePath = useUserHomePath(); // 关键代码行注释:获取用户首页路径 // 关键代码行注释:如果还未挂载,显示加载状态避免闪烁 if (!mounted) { @@ -221,13 +230,30 @@ export default function HomePage(): React.ReactElement { {isDark ? : } - + {/* 关键代码行注释:根据用户登录状态显示不同的按钮 */} + {userInfo && userInfo._id ? ( + // 已登录状态:显示控制台按钮 + + + + ) : ( + // 未登录状态:显示登录和注册按钮 + <> + + + - + + + + + )}
diff --git a/src/pages/team/AfterSaleRecord/EditAfterSalesModal.tsx b/src/pages/team/AfterSaleRecord/EditAfterSalesModal.tsx new file mode 100644 index 0000000..00c467e --- /dev/null +++ b/src/pages/team/AfterSaleRecord/EditAfterSalesModal.tsx @@ -0,0 +1,206 @@ +/** + * 文件: EditAfterSalesModal.tsx + * 作者: 阿瑞 + * 功能: 编辑售后记录模态框组件 + * 版本: v2.0.0 - 使用 fetch 替换 axios + */ +import React, { useEffect, useState } from 'react'; +import { Modal, Form, Input, Button, Select, InputNumber, App } from 'antd'; +import { IAfterSalesRecord, IPaymentPlatform, IProduct } from '@/models/types'; +import { useUserInfo } from '@/store/userStore'; + +interface EditAfterSalesModalProps { + visible: boolean; + onOk: () => void; + onCancel: () => void; + record: IAfterSalesRecord | null; // 传入的售后记录 +} + +const EditAfterSalesModal: React.FC = ({ visible, onOk, onCancel, record }) => { + const { message } = App.useApp(); // 使用 App.useApp 获取 message 实例 + const [form] = Form.useForm(); + const userInfo = useUserInfo(); + const [paymentPlatforms, setPaymentPlatforms] = useState([]); + const [products, setProducts] = useState([]); + + // 获取产品数据 + 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 || []); + } catch (error: unknown) { + console.error('加载产品数据失败:', error); + message.error('加载产品数据失败'); + } + }; + + //获取收支平台 + 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 || []); + } catch (error: unknown) { + console.error('加载平台数据失败:', error); + message.error('加载平台数据失败'); + setPaymentPlatforms([]); + } + }; + + useEffect(() => { + form.resetFields(); + if (record && userInfo.团队?._id) { + const teamId = userInfo.团队._id; + fetchPayPlatforms(teamId); + fetchProducts(teamId); // 加载产品列表 + + // 设置表单初始值 + const initialValues: any = { + 原因: record.原因, + 备注: record.备注, + 类型: record.类型, + 收支平台: record.收支平台?._id, + 收支金额: record.收支金额, + 待收: record.待收, + 收支类型: record.收支类型, + }; + + // 处理替换产品 + if (record.替换产品 && Array.isArray(record.替换产品)) { + // 如果替换产品是对象数组,提取ID + if (record.替换产品.length > 0 && typeof record.替换产品[0] === 'object') { + initialValues.替换产品 = record.替换产品.map( + (product: any) => product._id || product + ); + } else { + // 如果已经是ID数组,直接使用 + initialValues.替换产品 = record.替换产品; + } + + console.log('设置替换产品值:', initialValues.替换产品); + } + + form.setFieldsValue(initialValues); + } + }, [record, userInfo.团队?._id, form]); + + const handleOk = async () => { + try { + const replacementProductIds = form.getFieldValue('替换产品') || []; + const values = await form.validateFields(); + + // 构建更新对象,确保正确处理所有需要更新的字段 + const updateData = { + 原因: values.原因, + 备注: values.备注, + 类型: values.类型, + 收支平台: values.收支平台 ? { _id: values.收支平台 } : null, + 收支金额: values.收支金额, + 待收: values.待收, + 收支类型: values.收支类型, + 替换产品: replacementProductIds, // 使用表单中的替换产品ID数组 + }; + + console.log('更新售后记录数据:', updateData); + + // 调用API更新售后记录 + const response = await fetch(`/api/backstage/sales/aftersale/records/${record?._id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + message.success('售后记录更新成功'); + onOk(); + } catch (error: unknown) { + console.error('更新售后记录失败:', error); + message.error('更新售后记录失败'); + } + }; + + return ( + + 取消 + , + , + ]} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default EditAfterSalesModal; diff --git a/src/pages/team/AfterSaleRecord/MultiAfterSalesModal.tsx b/src/pages/team/AfterSaleRecord/MultiAfterSalesModal.tsx new file mode 100644 index 0000000..a9958a4 --- /dev/null +++ b/src/pages/team/AfterSaleRecord/MultiAfterSalesModal.tsx @@ -0,0 +1,339 @@ +/** + * 文件: MultiAfterSalesModal.tsx + * 作者: 阿瑞 + * 功能: 多重售后操作模态框组件 + * 版本: v2.0.0 - 使用 fetch 替换 axios + */ +import React, { useEffect, useState } from 'react'; +import { Modal, Form, Select, DatePicker, Button, InputNumber, Input, App } from 'antd'; +import { IAfterSalesRecord, IPaymentPlatform, IProduct } from '@/models/types'; +import dayjs from 'dayjs'; +import { useUserInfo } from '@/store/userStore'; +import ProductImage from '@/components/product/ProductImage'; +import AddProductComponent from '../sale/components/AddProductComponent'; +import { CloseOutlined } from '@ant-design/icons'; + +interface MultiAfterSalesModalProps { + visible: boolean; + onOk: () => void; + onCancel: () => void; + record: IAfterSalesRecord | null; // 传递当前的售后记录 + type: '退货' | '换货' | '补发' | '补差'; +} + +const { Option } = Select; + +const MultiAfterSalesModal: React.FC = ({ visible, onOk, onCancel, record, type }) => { + const { message } = App.useApp(); // 使用 App.useApp 获取 message 实例 + const [form] = Form.useForm(); + const [paymentPlatforms, setPaymentPlatforms] = useState([]); + const [selectedProducts, setSelectedProducts] = useState([]); + const [products, setProducts] = useState([]); + const userInfo = useUserInfo(); + const [paymentCode, setPaymentCode] = useState(null); + + //获取收支平台 + 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 || []); + } catch (error: unknown) { + console.error('加载平台数据失败:', error); + message.error('加载平台数据失败'); + setPaymentPlatforms([]); + } + }; + + // 获取产品数据 + 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 || []); + } catch (error: unknown) { + console.error('加载产品数据失败:', error); + message.error('加载产品数据失败'); + } + }; + + useEffect(() => { + form.resetFields(); + setSelectedProducts([]); + if (record && userInfo.团队?._id) { + const teamId = userInfo.团队._id; + fetchPayPlatforms(teamId); + fetchProducts(teamId); // 加载产品列表 + + form.setFieldsValue({ + 前一次售后: record._id, // 使用当前售后记录的ID作为前一次售后 + 类型: type, + 日期: dayjs(), + }); + } + }, [record, type, userInfo.团队?._id]); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + if (!record) { + message.error('未选择售后记录,无法创建新的售后记录'); + return; + } + const replacementProductIds = selectedProducts.map(product => product._id); + let paymentCodeId = null; + if (paymentCode) { + const response = await fetch('/api/backstage/sales/aftersale/uploadPaymentCode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ 收款码: paymentCode }), + }); + + if (!response.ok) { + throw new Error('收款码上传失败'); + } + + const paymentCodeResponse = await response.json(); + paymentCodeId = paymentCodeResponse.paymentCodeId; + } + const afterSalesData = { + 销售记录: record.销售记录._id, + 前一次售后: record._id, // 传递前一次售后的 ID + 类型: type, + 原产品: values.原产品, // 传递原产品的 ID 数组 + 替换产品: replacementProductIds, // 传递替换产品的 ID 数组 + 团队: userInfo.团队?._id, + 日期: values.日期.toISOString(), + 收款码: paymentCodeId, // 添加收款码ID字段 + 原因: values.原因, // 售后原因 + 收支类型: values.收支类型, + 收支平台: values.收支平台, + 收支金额: values.收支金额, + 待收: values.待收, + 备注: values.备注, + }; + + const response = await fetch('/api/backstage/sales/aftersale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(afterSalesData), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + message.success(`${type}操作成功`); + onOk(); + } catch (error: unknown) { + console.error(`${type}操作失败:`, error); + message.error(`${type}操作失败`); + } + }; + + const [isProductModalVisible, setIsProductModalVisible] = useState(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 renderAdditionalFields = () => { + if (type === '换货' || type === '补发') { + return ( +
+
+ {selectedProducts.map(product => ( +
+ + {product.名称} +
+ ))} +
+ + setIsProductModalVisible(false)} + onSuccess={handleAddProductSuccess} + /> + + + +
+ ); + } + return null; + }; + + const handlePaste = (event: React.ClipboardEvent) => { + const items = event.clipboardData.items; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = async (e) => { + const img = new Image(); + img.src = e.target?.result as string; + img.onload = async () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const width = img.width / 2; + const height = img.height / 2; + canvas.width = width; + canvas.height = height; + ctx?.drawImage(img, 0, 0, width, height); + const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.8); + setPaymentCode(compressedDataUrl); + }; + }; + reader.readAsDataURL(file); + } + } + } + }; + + return ( + + 取消 + , + , + ]} + width="76vw" + style={{ top: 20 }} + > +
+
+
+

售后信息

+ + + + + + + +
+
+

售后信息

+ {renderAdditionalFields()} +
+ + + + + + +
+
+

收支信息

+ + + + + + + + + + + + + +
+ {paymentCode ? ( + <> + Payment Code + + + ) : ( +

粘贴图片到此区域

+ )} +
+
+
+
+
+
+ ); +}; + +export default MultiAfterSalesModal; diff --git a/src/pages/team/AfterSaleRecord/index.tsx b/src/pages/team/AfterSaleRecord/index.tsx new file mode 100644 index 0000000..1681a0c --- /dev/null +++ b/src/pages/team/AfterSaleRecord/index.tsx @@ -0,0 +1,799 @@ +/** + * 文件: AfterSaleRecord/index.tsx + * 作者: 阿瑞 + * 功能: 售后记录管理页面 - 展示和管理售后记录 + * 版本: v2.0.0 - 使用 fetch 替换 axios + */ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { Table, Button, Card, Tag, Tooltip + , Input, Space, DatePicker, App } from 'antd'; +import { IAfterSalesRecord } from '@/models/types'; +import dayjs from 'dayjs'; +import { useUserInfo } from '@/store/userStore'; +import { ColumnsType } from 'antd/es/table'; +import { useRouter } from 'next/router'; +import { CheckCircleOutlined, ClockCircleOutlined, DownloadOutlined, FieldTimeOutlined, IdcardOutlined, MobileOutlined, SyncOutlined, UserOutlined, WechatOutlined + , SearchOutlined + } from '@ant-design/icons'; +import { IconButton, Iconify } from '@/components/icon'; +import PaymentCodeImageComponent from '@/pages/components/PaymentCodeImage'; +import MultiAfterSalesModal from './MultiAfterSalesModal'; +import ShipModal from './ship-modal'; +import EditAfterSalesModal from './EditAfterSalesModal'; // 引入编辑售后模态框 +import ProductCardList from '@/components/product/ProductCardList'; // 引入产品卡片列表组件 +import MyTooltip from '@/components/tooltip/MyTooltip'; + +// 导出Excel相关库 +//import * as XLSX from 'xlsx'; +//import { saveAs } from 'file-saver'; + +const AfterSaleRecordPage = () => { + const { message } = App.useApp(); // 使用 App.useApp 获取 message 实例 + const [records, setRecords] = useState([]); + const [filteredRecords, setFilteredRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [transactionDateRange, setTransactionDateRange] = useState<[Date | null, Date | null]>([null, null]); + const [afterSaleDateRange, setAfterSaleDateRange] = useState<[Date | null, Date | null]>([null, null]); + const [transactionDateValue, setTransactionDateValue] = useState(null); + const [afterSaleDateValue, setAfterSaleDateValue] = useState(null); + const userInfo = useUserInfo(); + const router = useRouter(); + + const userRole = userInfo?.角色?.名称; // 用户角色名称 + const isAdmin = userRole === '系统管理员' || userRole === '团队管理员'; // 是否为管理员角色 + //const isAdmin = userInfo.角色?.名称 === '系统管理员'; + /* + // 获取用户角色 + const userRole = userInfo?.角色?.名称; // 用户角色名称 + const isAdmin = userRole === '系统管理员' || userRole === '团队管理员'; // 是否为管理员角色 + const isNotFinanceRole = userRole !== '财务'; // 判断是否不是财务角色 + */ + const [currentafterSalesRecord, setCurrentafterSalesRecord] = useState(null); // 当前操作的售后记录 + const [multiafterSalesVisible, setMultiafterSalesVisible] = useState(false); + const [multiafterSalesType, setMultiAfterSalesType] = useState<'退货' | '换货' | '补发' | '补差' | null>(null); + const [isShipModalVisible, setIsShipModalVisible] = useState(false); // 是否显示发货模态框 + const [currentRecord, setCurrentRecord] = useState(null); // 当前选中的销售记录 + const [editModalVisible, setEditModalVisible] = useState(false); // 是否显示编辑模态框 + const [currentEditRecord, setCurrentEditRecord] = useState(null); // 当前选中的售后记录 + + // 使用useCallback优化函数,避免不必要的重新创建 + const fetchRecords = useCallback(async (teamId: string) => { + setLoading(true); + try { + const response = await fetch(`/api/backstage/sales/aftersale/records?teamId=${teamId}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + setRecords(data.records); + setFilteredRecords(data.records); // 初始化filteredRecords为所有记录 + } catch (error: unknown) { + console.error('加载售后记录失败:', error); + message.error('加载售后记录失败'); + } finally { + setLoading(false); + } + }, [message]); + + const showShipModal = useCallback((record: IAfterSalesRecord) => { + setCurrentRecord(record); + setIsShipModalVisible(true); // 显示发货模态框 + }, []); + + const handleShipModalOk = useCallback(async () => { + setIsShipModalVisible(false); // 关闭发货模态框 + // 发货操作完成后重新获取数据 + if (userInfo.团队?._id) { + await fetchRecords(userInfo.团队._id); + } + }, [userInfo.团队?._id, fetchRecords]); + + const handleModalCancel = useCallback(() => { + //setIsModalVisible(false); + setIsShipModalVisible(false); // 关闭发货模态框 + }, []); + + const showEditModal = useCallback((record: IAfterSalesRecord) => { + setCurrentEditRecord(record); // 设置当前要编辑的售后记录 + setEditModalVisible(true); // 显示编辑模态框 + }, []); + + const handleEditModalOk = useCallback(async () => { + setEditModalVisible(false); // 关闭编辑模态框 + // 编辑操作完成后重新加载售后记录 + if (userInfo.团队?._id) { + await fetchRecords(userInfo.团队._id); + } + }, [userInfo.团队?._id, fetchRecords]); + + const handleEditModalCancel = useCallback(() => { + setEditModalVisible(false); // 关闭编辑模态框 + }, []); + + const handleMultiAfterSales = useCallback((record: IAfterSalesRecord, type: '退货' | '换货' | '补发' | '补差') => { + setCurrentafterSalesRecord(record); + setMultiAfterSalesType(type); + setMultiafterSalesVisible(true); + }, []); + + const handleMultiAfterSalesModalOk = useCallback(async () => { + setMultiafterSalesVisible(false); // 关闭模态框 + // 多重售后操作完成后重新获取数据 + if (userInfo.团队?._id) { + await fetchRecords(userInfo.团队._id); + } + }, [userInfo.团队?._id, fetchRecords]); + + // 售后进度更新函数 - 提取到组件顶层避免重复创建 + const handleProgressChange = useCallback(async (id: string, newStatus: "待处理" | "处理中" | "已处理") => { + try { + if (!id) { + throw new Error("无效的售后记录 ID"); + } + + const response = await fetch('/api/backstage/sales/aftersale/records/updateProgress', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id, progress: newStatus }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // 更新本地状态 + setRecords(prevRecords => + prevRecords.map((record: IAfterSalesRecord) => + record._id.toString() === id ? { ...record, 售后进度: newStatus } : record + ) + ); + + message.success('售后进度更新成功'); + + // 进度更新后重新获取数据确保同步 + if (userInfo.团队?._id) { + await fetchRecords(userInfo.团队._id); + } + } catch (error: unknown) { + console.error('售后进度更新失败:', error); + message.error('售后进度更新失败'); + } + }, [userInfo?.团队?._id, fetchRecords, message]); + + useEffect(() => { + if (userInfo.团队?._id) { + fetchRecords(userInfo.团队._id); + } + }, [userInfo.团队?._id, fetchRecords]); + + // 筛选记录的函数 + useEffect(() => { + let filtered = [...records]; + + // 根据成交日期范围筛选 + if (transactionDateRange[0] && transactionDateRange[1]) { + filtered = filtered.filter(record => { + if (!record.销售记录.成交日期) return false; + const recordDate = new Date(record.销售记录.成交日期); + // 确保日期比较是准确的 + return recordDate >= transactionDateRange[0]! && + recordDate <= transactionDateRange[1]!; + }); + } + + // 根据售后日期范围筛选 + if (afterSaleDateRange[0] && afterSaleDateRange[1]) { + filtered = filtered.filter(record => { + if (!record.日期) return false; + const afterSaleDate = new Date(record.日期); + // 确保日期比较是准确的 + return afterSaleDate >= afterSaleDateRange[0]! && + afterSaleDate <= afterSaleDateRange[1]!; + }); + } + + setFilteredRecords(filtered); + }, [records, transactionDateRange, afterSaleDateRange]); + + const columns: ColumnsType = useMemo(() => [ + { + title: '销售记录', + onHeaderCell: () => { + return { + style: { + backgroundColor: '#ffbb96', + } + }; + }, + children: [ + //原订单信息 + { + title: '客户信息', + width: 160, + key: '客户信息', + // 增加filterDropdown用于尾号查询 + filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }) => ( +
+ setSelectedKeys(e.target.value ? [e.target.value] : [])} + onPressEnter={() => confirm()} + style={{ marginBottom: 8, display: 'block' }} + /> + + + + +
+ ), + filterIcon: filtered => ( + + ), + onFilter: (value, record: IAfterSalesRecord) => { + const phoneTail = record.销售记录.客户?.电话 + ? record.销售记录.客户.电话.slice(-4) + : ''; + const searchVal = String(value); + return phoneTail.includes(searchVal); + }, + render: (record: IAfterSalesRecord) => { + // 将地址的各个部分拼接成一个字符串 + const address = record.销售记录.客户?.地址 + ? `${record.销售记录.客户.地址.省份 ?? ''} ${record.销售记录.客户.地址.城市 ?? ''} ${record.销售记录.客户.地址.区县 ?? ''} ${record.销售记录.客户.地址.详细地址 ?? ''}` + : '未知'; + const customerInfo = `姓名:${record.销售记录.客户?.姓名 ?? '未知'}\n电话:${record.销售记录.客户?.电话 ?? '未知'}\n地址:${address}`; + const customerName = record.销售记录.客户?.姓名 ?? '未知'; + //显示账号编号 + const accountNumber = record.销售记录.订单来源?.账号编号 ?? '未知'; + return ( +
+ {isAdmin && ( + { + try { + await navigator.clipboard.writeText(customerInfo); + message.success(`客户 ${customerName} 信息复制成功!`); + } catch (error) { + console.error('客户信息复制失败:', error); + message.error('客户信息复制失败'); + } + }} + > + + + )} + +
+ } color="">{record.销售记录.客户?.姓名 ?? '未知'} + } color="">{record.销售记录.成交日期 ? new Date(record.销售记录.成交日期).toLocaleDateString() : '未知'} + } color="">{record.销售记录.客户?.电话 ? `****${record.销售记录.客户.电话.slice(-4)}` : '未知'} + {/*
状态:{record.销售记录?.订单状态?.[0]}
*/} + {/* 将最后两个 Tag 放在一个 span 中,并使用 no-wrap 样式避免自动换行 */} + + } color=""> + {record.销售记录?.导购?.姓名 ?? '未知'} + + } color="green"> + {accountNumber} + + +
+
+
+ ); + }, + }, + { + title: '售后产品', + dataIndex: '原产品', + //width: 260, + key: 'originalProducts', + render: (products: any[], record: IAfterSalesRecord) => { + return ; + } + }, + + { + title: '财务信息', + key: '财务信息', + width: 110, + render: (record: IAfterSalesRecord) => { + return ( +
+
平台:{record.销售记录?.收款平台?.名称}
+
应收:{record.销售记录?.应收金额}
+
已收:{record.销售记录?.收款金额}
+
待收:{record.销售记录?.待收款}
+
+ ); + }, + }, + ], + }, + { + title: '售后信息', + key: 'reasonAndType', + //筛选售后类型 + filters: [ + { text: '退货', value: '退货' }, + { text: '换货', value: '换货' }, + { text: '补发', value: '补发' }, + { text: '补差', value: '补差' }, + ], + onFilter: (value, record) => record.类型 === value, + render: (record: IAfterSalesRecord) => ( +
+
售后日期:{dayjs(record.日期).format('YYYY-MM-DD')}
+
原因:{record.原因}
+ {record.类型} +
备注:{record.备注}
+
+ ), + }, + { + title: '售后信息', + onHeaderCell: () => { + return { + style: { + backgroundColor: '#ffbb96', + } + }; + }, + children: [ + { + title: '替换产品', + dataIndex: '替换产品', + key: 'replacementProducts', + render: (products: any[], record: IAfterSalesRecord) => { + return ; + } + }, + //财务信息 + { + title: '财务信息', + key: '财务信息', + width: 110, + render: (record: IAfterSalesRecord) => { + return ( +
+
平台:{record.收支平台?.名称}
+
类型:{record.收支类型}
+
金额:{record.收支金额}
+
待收:{record.待收}
+
+ ); + }, + }, + { + title: '售后进度', + key: '售后进度', + width: 100, + filters: [ + { text: '待处理', value: '待处理' }, + { text: '处理中', value: '处理中' }, + { text: '已处理', value: '已处理' }, + ], + onFilter: (value, record) => record.售后进度 === value, + render: (_: any, record: IAfterSalesRecord) => { + const 售后进度 = record.售后进度; + const progressOptions = [ + { label: '待处理', value: '待处理', color: 'yellow', icon: }, + { label: '处理中', value: '处理中', color: 'blue', icon: 售后进度 === '处理中' ? : }, + { label: '已处理', value: '已处理', color: 'green', icon: }, + ]; + + return ( +
+ {progressOptions.map((option) => ( + handleProgressChange(record._id, option.value as "待处理" | "处理中" | "已处理")} + > + {option.label} + + ))} +
+ ); + }, + }, + ], + }, + //收款码,使用PaymentCodeImageComponent paymentCodeId= 组件显示图片 + { + title: '收款码', + key: '收款码', + render: (record: IAfterSalesRecord) => { + return record.收款码 ? ( +
+ +
+ ) : ( +
无收款码
+ ); + }, + }, + + { + title: '操作', + key: 'action', + align: 'center', + width: 160, + render: (_: any, record: IAfterSalesRecord) => ( +
+
+ + + + + + + + + + + + +
+
+ + +
+
+ ), + }, + ], [router, records, userInfo?.团队?._id, fetchRecords, isAdmin, handleMultiAfterSales, showShipModal, showEditModal, handleProgressChange]); + + // 重置筛选条件 + const handleReset = () => { + setTransactionDateRange([null, null]); + setAfterSaleDateRange([null, null]); + // 清空日期选择器显示的值 + setTransactionDateValue(null); + setAfterSaleDateValue(null); + }; + + // 导出Excel的函数 + /* + const exportToExcel = () => { + if (filteredRecords.length === 0) { + message.warning('没有可导出的数据'); + return; + } + + message.loading({ content: '正在准备导出数据...', key: 'exporting', duration: 0 }); + + try { + // 准备导出数据 + const exportData = filteredRecords.map((record, index) => { + // 处理原产品信息 - 合并成一个字符串 + const originalProductsText = record.原产品?.map(product => + `${product.名称 || '未知'}` + ).join(', ') || '无产品'; + + // 处理替换产品信息 + const replacementProductsText = record.替换产品?.map(product => + `${product.名称 || '未知'}` + ).join(', ') || '无替换产品'; + + // 格式化日期 + const formatDate = (dateStr: string | undefined | Date): string => { + if (!dateStr) return '未知'; + try { + const date = dateStr instanceof Date ? dateStr : new Date(dateStr); + if (isNaN(date.getTime())) return '无效日期'; + return date.toLocaleDateString('zh-CN'); + } catch { + return '无效日期'; + } + }; + + // 处理电话号码 - 只保留尾号 + const phoneNumber = record.销售记录.客户?.电话 + ? `${record.销售记录.客户.电话.slice(-4)}` + : '未知'; + + // 返回导出的行数据 + return { + '序号': index + 1, + '客户姓名': record.销售记录.客户?.姓名 || '未知', + '电话尾号': phoneNumber, + '成交日期': record.销售记录.成交日期 ? formatDate(record.销售记录.成交日期) : '未知', + '售后日期': formatDate(record.日期), + '售后类型': record.类型 || '未知', + '售后原因': record.原因 || '未知', + '售后进度': record.售后进度 || '未知', + '导购': record.销售记录.导购?.姓名 || '未知', + '账号编号': record.销售记录.订单来源?.账号编号 || '未知', + '原产品信息': originalProductsText, + '替换产品信息': replacementProductsText, + '应收金额': record.销售记录?.应收金额 || 0, + '收款金额': record.销售记录?.收款金额 || 0, + '收支平台': record.收支平台?.名称 || '未知', + '收支类型': record.收支类型 || '未知', + '收支金额': record.收支金额 || 0, + '待收': record.待收 || 0, + '备注': record.备注 || '无', + }; + }); + + // 计算统计数据 + let totalIncome = 0; + let totalExpense = 0; + + filteredRecords.forEach(record => { + if (record.收支类型 === '收入' && record.收支金额) { + totalIncome += record.收支金额; + } else if (record.收支类型 === '支出' && record.收支金额) { + totalExpense += record.收支金额; + } + }); + // 创建工作簿和工作表 + //const ws = XLSX.utils.json_to_sheet(exportData); + //const wb = XLSX.utils.book_new(); + //XLSX.utils.book_append_sheet(wb, ws, '售后记录'); + + // 设置列宽 (大致估计,可能需要调整) + const colWidths = [ + { wch: 5 }, // 序号 + { wch: 10 }, // 客户姓名 + { wch: 10 }, // 电话尾号 + { wch: 10 }, // 成交日期 + { wch: 10 }, // 售后日期 + { wch: 8 }, // 售后类型 + { wch: 20 }, // 售后原因 + { wch: 8 }, // 售后进度 + { wch: 10 }, // 导购 + { wch: 10 }, // 账号编号 + { wch: 40 }, // 原产品信息 + { wch: 40 }, // 替换产品信息 + { wch: 10 }, // 应收金额 + { wch: 10 }, // 收款金额 + { wch: 10 }, // 收支平台 + { wch: 10 }, // 收支类型 + { wch: 10 }, // 收支金额 + { wch: 10 }, // 待收 + { wch: 20 }, // 备注 + ]; + //ws['!cols'] = colWidths; + + // 生成Excel文件并下载 + const now = new Date(); + const dateStr = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}`; + const fileName = `售后记录导出_${dateStr}.xlsx`; + + // 使用file-saver保存文件 + const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([wbout], { type: 'application/octet-stream' }); + saveAs(blob, fileName); + + message.success({ content: '导出成功!', key: 'exporting' }); + } catch (error) { + console.error('导出Excel失败:', error); + message.error({ content: '导出失败,请重试', key: 'exporting' }); + } + }; + */ + + return ( + +
+
+ 成交日期: + { + setTransactionDateValue(dates); + if (dateStrings[0] && dateStrings[1]) { + // 设置开始日期为当天的00:00:00 + const startDate = new Date(dateStrings[0]); + startDate.setHours(0, 0, 0, 0); + + // 设置结束日期为当天的23:59:59,确保包含当天的所有记录 + const endDate = new Date(dateStrings[1]); + endDate.setHours(23, 59, 59, 999); + + setTransactionDateRange([startDate, endDate]); + } else { + setTransactionDateRange([null, null]); + } + }} + allowClear={true} + /> +
+
+ 售后日期: + { + setAfterSaleDateValue(dates); + if (dateStrings[0] && dateStrings[1]) { + // 设置开始日期为当天的00:00:00 + const startDate = new Date(dateStrings[0]); + startDate.setHours(0, 0, 0, 0); + + // 设置结束日期为当天的23:59:59,确保包含当天的所有记录 + const endDate = new Date(dateStrings[1]); + endDate.setHours(23, 59, 59, 999); + + setAfterSaleDateRange([startDate, endDate]); + } else { + setAfterSaleDateRange([null, null]); + } + }} + allowClear={true} + /> +
+ + {(transactionDateRange[0] || afterSaleDateRange[0]) && ( + + + 共: {filteredRecords.length} / {records.length} 条记录 + + + )} + + {/* 添加导出按钮 */} + + + {/* 添加售后统计信息 */} +
+ {(() => { + // 计算售后收入总和、支出总和和售后总额 + const calculateAfterSaleStats = () => { + let totalIncome = 0; + let totalExpense = 0; + + filteredRecords.forEach(record => { + // 根据收支类型计算收入或支出 + if (record.收支类型 === '收入' && record.收支金额) { + totalIncome += record.收支金额; + } else if (record.收支类型 === '支出' && record.收支金额) { + totalExpense += record.收支金额; + } + }); + + // 计算售后总额(收入-支出) + const totalAfterSale = totalIncome - totalExpense; + + return { + totalIncome, + totalExpense, + totalAfterSale + }; + }; + + const stats = calculateAfterSaleStats(); + + return ( + <> + + + 收入: ¥{stats.totalIncome.toFixed(2)} + + + + + 支出: ¥{stats.totalExpense.toFixed(2)} + + + + = 0 ? "blue" : "volcano"}> + 售后总额: ¥{stats.totalAfterSale.toFixed(2)} + + + + ); + })()} +
+
+ + setMultiafterSalesVisible(false)} + record={currentafterSalesRecord} + type={multiafterSalesType!} + /> + + + + ); +}; + +export default AfterSaleRecordPage; diff --git a/src/pages/team/AfterSaleRecord/ship-modal.tsx b/src/pages/team/AfterSaleRecord/ship-modal.tsx new file mode 100644 index 0000000..f7c4605 --- /dev/null +++ b/src/pages/team/AfterSaleRecord/ship-modal.tsx @@ -0,0 +1,202 @@ +/** + * 文件: ship-modal.tsx + * 作者: 阿瑞 + * 功能: 售后发货模态框组件 + * 版本: v2.0.0 - 使用 fetch 替换 axios + */ +import React, { useEffect, useState } from 'react'; +import { Modal, Form, Input, Button, App } from 'antd'; +import { IAfterSalesRecord } from '@/models/types'; +import { useUserInfo } from '@/store/userStore'; + +interface ShipModalProps { + visible: boolean; + onOk: () => void; + onCancel: () => void; + record: IAfterSalesRecord | null; // 传入的售后记录 +} + +const ShipModal: React.FC = ({ visible, onOk, onCancel, record }) => { + const { message } = App.useApp(); // 使用 App.useApp 获取 message 实例 + 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] = ''; // 初始化原产品的物流单号为空 + }); + + // 初始化替换产品的物流单号 + record?.替换产品?.forEach(product => { + initialLogisticsNumbers[product._id] = ''; // 初始化替换产品的物流单号为空 + }); + + setLogisticsNumbers(initialLogisticsNumbers); + + // 获取已有的物流记录并填充单号 + const fetchLogisticsRecords = async () => { + try { + const response = await fetch(`/api/tools/logistics?关联记录=${record._id}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const logisticsRecords = await response.json(); + 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 (error: unknown) { + console.error('获取物流记录失败:', error); + message.error('获取物流记录失败'); + } + }; + + fetchLogisticsRecords(); + } + }, [record, form, message]); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + + // 过滤出有物流单号的产品 + const originalProductsWithLogisticsNumbers = (record?.原产品 || []) + .map(product => ({ + productId: product._id, + logisticsNumber: logisticsNumbers[product._id] + })) + .filter(item => item.logisticsNumber); // 只保留填写了物流单号的原产品 + + const replacementProductsWithLogisticsNumbers = (record?.替换产品 || []) + .map(product => ({ + productId: product._id, + logisticsNumber: logisticsNumbers[product._id] + })) + .filter(item => item.logisticsNumber); // 只保留填写了物流单号的替换产品 + + const productsWithLogisticsNumbers = [...originalProductsWithLogisticsNumbers, ...replacementProductsWithLogisticsNumbers]; + + if (productsWithLogisticsNumbers.length === 0) { + message.error('请至少为一个产品填写物流单号'); + return; + } + + const logisticsData = { + ...values, + 团队: userInfo.团队?._id, + 关联记录: record?._id, + 类型: 'AfterSalesRecord', // 确保类型为售后记录 + 产品: productsWithLogisticsNumbers // 只提交填写了物流单号的产品 + }; + + const response = await fetch('/api/tools/logistics', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(logisticsData), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + message.success('发货信息提交成功'); + onOk(); // 关闭模态框 + } catch (error: unknown) { + console.error('发货信息提交失败:', error); + message.error('发货信息提交失败'); + } + }; + + const handleLogisticsNumberChange = (productId: string, value: string) => { + setLogisticsNumbers(prevState => ({ + ...prevState, + [productId]: value // 更新每个产品的物流单号 + })); + }; + + return ( + + 取消 + , + , + ]} + > +
+ + + + + {/* 动态生成原产品的物流单号输入框 */} + {record?.原产品?.map(product => ( + + handleLogisticsNumberChange(product._id, e.target.value)} + /> + + ))} + + {/* 动态生成替换产品的物流单号输入框 */} + {record?.替换产品?.map(product => ( + + handleLogisticsNumberChange(product._id, e.target.value)} + onChange={e => { + const cleanedValue = e.target.value.replace(/[\s.,/#!$%\^&\*;:{}=\-_`~()<>[\]'"|\\?@+]/g, '');//过滤特殊字符和空格 + handleLogisticsNumberChange(product._id, cleanedValue); + }} + /> + + ))} + +
+ ); +}; + +export default ShipModal; diff --git a/src/pages/team/sale/components/AddProductComponent.tsx b/src/pages/team/sale/components/AddProductComponent.tsx index c731cc1..a6e78e8 100644 --- a/src/pages/team/sale/components/AddProductComponent.tsx +++ b/src/pages/team/sale/components/AddProductComponent.tsx @@ -319,7 +319,7 @@ const AddProductComponent: React.FC = ({ visible, onClose, onSu onCancel={onClose} width="90%" footer={null} - destroyOnClose={true} + destroyOnHidden={true} maskClosable={false} zIndex={1100} getContainer={false} diff --git a/src/pages/team/sale/index.tsx b/src/pages/team/sale/index.tsx index 0852e0f..8dec1df 100644 --- a/src/pages/team/sale/index.tsx +++ b/src/pages/team/sale/index.tsx @@ -200,11 +200,11 @@ const SalesRecordPage: React.FC = ({ salesRecord, onCancel 描述: '销售余额抵扣', }) }); - + if (!transactionResponse.ok) { throw new Error(`Transaction failed! status: ${transactionResponse.status}`); } - + const transactionData = await transactionResponse.json(); transactionId = transactionData._id; // 获取创建的交易记录的ID console.log('生成的transactionId:', transactionId); // 打印transactionId @@ -228,7 +228,7 @@ const SalesRecordPage: React.FC = ({ salesRecord, onCancel }, body: JSON.stringify(requestData) }); - + if (!salesResponse.ok) { throw new Error(`Sales record failed! status: ${salesResponse.status}`); } @@ -280,12 +280,12 @@ const SalesRecordPage: React.FC = ({ salesRecord, onCancel const totalPrice = selectedProducts.reduce((acc, cur) => acc + cur.售价, 0); - // 定义处理编辑客户后的回调 - const handleEditCustomerSuccess = (updatedCustomer: ICustomer) => { - setSelectedCustomer(updatedCustomer); // 更新选中的客户 - message.success('客户信息已更新'); - }; - + // 定义处理编辑客户后的回调 + const handleEditCustomerSuccess = (updatedCustomer: ICustomer) => { + setSelectedCustomer(updatedCustomer); // 更新选中的客户 + message.success('客户信息已更新'); + }; + return (
@@ -293,7 +293,7 @@ const SalesRecordPage: React.FC = ({ salesRecord, onCancel //padding: '20px', borderRadius: '8px', }}> - +
= ({ salesRecord, onCancel } - bordered style={{ width: '100%', borderRadius: '8px', boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}> + variant="outlined" + style={{ width: '100%', borderRadius: '8px', boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
卡片组件
- +

这是一个基础的卡片组件

可以包含任何内容

diff --git a/src/theme/hooks/index.ts b/src/theme/hooks/index.ts new file mode 100644 index 0000000..107354b --- /dev/null +++ b/src/theme/hooks/index.ts @@ -0,0 +1,2 @@ +export { useThemeToken } from './use-theme-token'; +export { useResponsive } from './use-reponsive'; diff --git a/src/theme/hooks/use-reponsive.ts b/src/theme/hooks/use-reponsive.ts new file mode 100644 index 0000000..d8c0e29 --- /dev/null +++ b/src/theme/hooks/use-reponsive.ts @@ -0,0 +1,40 @@ +import { Grid, theme } from 'antd'; +import { Breakpoint, ScreenMap, ScreenSizeMap } from 'antd/es/_util/responsiveObserver'; + +const { useBreakpoint } = Grid; + +export function useResponsive() { + const { + token: { screenXS, screenSM, screenMD, screenLG, screenXL, screenXXL }, + } = theme.useToken(); + const screenArray: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + + const screenEnum: ScreenSizeMap = { + xs: screenXS, + sm: screenSM, + md: screenMD, + lg: screenLG, + xl: screenXL, + xxl: screenXXL, + }; + const screenMap: ScreenMap = useBreakpoint(); + /* + const currentScrren = screenArray.findLast((item) => { + const result = screenMap[item]; + return result === true; + }); + */ + + // 使用 [...screenArray].reverse().find() 来代替 findLast 方法,避免兼容性问题 + // [...screenArray] 创建了一个 screenArray 的副本,这样 reverse 方法不会改变原数组的顺序 + const currentScrren = [...screenArray].reverse().find((item) => { + const result = screenMap[item]; + return result === true; + }); + + return { + screenEnum, + screenMap, + currentScrren, + }; +} diff --git a/src/theme/hooks/use-theme-token.ts b/src/theme/hooks/use-theme-token.ts new file mode 100644 index 0000000..9576a68 --- /dev/null +++ b/src/theme/hooks/use-theme-token.ts @@ -0,0 +1,9 @@ +import { theme } from 'antd'; +import { useMemo } from 'react'; + +export function useThemeToken() { + const { token } = theme.useToken(); + + // 确保 useMemo 仅依赖稳定的值 + return useMemo(() => token, [token]); +}