通话逻辑调整

This commit is contained in:
2025-06-29 01:33:41 +08:00
parent deb2900acc
commit 48d22a1e94
37 changed files with 9242 additions and 238 deletions
+413 -7
View File
@@ -8,6 +8,7 @@
"name": "twilioapp-admin",
"version": "0.1.0",
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
@@ -23,6 +24,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.0",
"react-scripts": "5.0.1",
"recharts": "^3.0.2",
"twilio": "^5.7.1",
"twilio-video": "^2.31.0",
"typescript": "^4.7.4",
@@ -112,15 +114,14 @@
}
},
"node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.0.0.tgz",
"integrity": "sha512-o0aCCAlHc1o4CQcapAwWzHeaW2x9F49g7P3IDtvtNXgHowtRWYb7kiubt8sQPFvfVIVU/jLw2hzeSlNt0FU+Uw==",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/colors": "^8.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
"@rc-component/util": "^1.2.1",
"classnames": "^2.2.6"
},
"engines": {
"node": ">=8"
@@ -135,6 +136,22 @@
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="
},
"node_modules/@ant-design/icons/node_modules/@ant-design/colors": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.0.tgz",
"integrity": "sha512-6YzkKCw30EI/E9kHOIXsQDHmMvTllT8STzjMb4K2qzit33RW2pqCJP0sk+hidBntXxE+Vz4n1+RvCTfBw6OErw==",
"dependencies": {
"@ant-design/fast-color": "^3.0.0"
}
},
"node_modules/@ant-design/icons/node_modules/@ant-design/fast-color": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.0.tgz",
"integrity": "sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA==",
"engines": {
"node": ">=8.x"
}
},
"node_modules/@ant-design/react-slick": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
@@ -3141,6 +3158,57 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@rc-component/util": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.2.1.tgz",
"integrity": "sha512-AUVu6jO+lWjQnUOOECwu8iR0EdElQgWW5NBv5vP/Uf9dWbAX3udhMutRlkVXjuac2E40ghkFy+ve00mc/3Fymg==",
"dependencies": {
"react-is": "^18.2.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/@rc-component/util/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/@reduxjs/toolkit": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
@@ -3254,6 +3322,16 @@
"@sinonjs/commons": "^1.7.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -3814,6 +3892,60 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/eslint": {
"version": "8.56.12",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
@@ -4082,6 +4214,11 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -4752,6 +4889,25 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/antd/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -5782,6 +5938,14 @@
"wrap-ansi": "^7.0.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -6468,6 +6632,116 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6560,6 +6834,11 @@
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@@ -7205,6 +7484,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.39.5",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz",
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ=="
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -9220,6 +9504,14 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -14298,6 +14590,28 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -14440,6 +14754,46 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.0.2.tgz",
"integrity": "sha512-eDc3ile9qJU9Dp/EekSthQPhAVPG48/uM47jk+PF7VBQngxeW3cwQpPHb/GHC1uqwyCRWXcIrDzuHRVrnRryoQ==",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"node_modules/recharts/node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/recursive-readdir": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
@@ -14463,6 +14817,19 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -14613,6 +14980,11 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -16367,6 +16739,11 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -16890,6 +17267,14 @@
"requires-port": "^1.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@@ -16968,6 +17353,27 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
+2
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
@@ -18,6 +19,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.0",
"react-scripts": "5.0.1",
"recharts": "^3.0.2",
"twilio": "^5.7.1",
"twilio-video": "^2.31.0",
"typescript": "^4.7.4",
+62 -15
View File
@@ -9,7 +9,10 @@ import {
UserOutlined,
TeamOutlined,
DollarOutlined,
SettingOutlined
SettingOutlined,
WalletOutlined,
BarChartOutlined,
CalculatorOutlined
} from '@ant-design/icons';
import zhCN from 'antd/locale/zh_CN';
import 'antd/dist/reset.css';
@@ -28,6 +31,11 @@ import TranslatorList from './pages/Translators/TranslatorList';
import PaymentList from './pages/Payments/PaymentList';
import SystemSettings from './pages/Settings/SystemSettings';
// 导入计费管理页面
import BillingRules from './pages/Billing/BillingRules';
import UserAccounts from './pages/Billing/UserAccounts';
import BillingStats from './pages/Billing/BillingStats';
const { Header, Sider, Content } = Layout;
const { Title } = Typography;
@@ -58,6 +66,15 @@ const AppContent: React.FC = () => {
case 'payments':
navigate('/payments');
break;
case 'billing-rules':
navigate('/billing/rules');
break;
case 'user-accounts':
navigate('/billing/accounts');
break;
case 'billing-stats':
navigate('/billing/stats');
break;
case 'settings':
navigate('/settings');
break;
@@ -97,6 +114,28 @@ const AppContent: React.FC = () => {
icon: <TeamOutlined />,
label: '译员管理',
},
{
key: 'billing',
icon: <CalculatorOutlined />,
label: '计费管理',
children: [
{
key: 'billing-rules',
icon: <CalculatorOutlined />,
label: '计费规则',
},
{
key: 'user-accounts',
icon: <WalletOutlined />,
label: '用户账户',
},
{
key: 'billing-stats',
icon: <BarChartOutlined />,
label: '计费统计',
},
],
},
{
key: 'payments',
icon: <DollarOutlined />,
@@ -156,20 +195,28 @@ const AppContent: React.FC = () => {
background: '#f0f2f5',
minHeight: 'calc(100vh - 64px)'
}}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/calls" element={<CallList />} />
<Route path="/calls/:id" element={<CallDetail />} />
<Route path="/documents" element={<DocumentList />} />
<Route path="/documents/:id" element={<DocumentDetail />} />
<Route path="/appointments" element={<AppointmentList />} />
<Route path="/appointments/:id" element={<AppointmentDetail />} />
<Route path="/users" element={<UserList />} />
<Route path="/translators" element={<TranslatorList />} />
<Route path="/payments" element={<PaymentList />} />
<Route path="/settings" element={<SystemSettings />} />
<Route path="*" element={<Dashboard />} />
</Routes>
<div style={{ padding: '24px' }}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/calls" element={<CallList />} />
<Route path="/calls/:id" element={<CallDetail />} />
<Route path="/documents" element={<DocumentList />} />
<Route path="/documents/:id" element={<DocumentDetail />} />
<Route path="/appointments" element={<AppointmentList />} />
<Route path="/appointments/:id" element={<AppointmentDetail />} />
<Route path="/users" element={<UserList />} />
<Route path="/translators" element={<TranslatorList />} />
<Route path="/payments" element={<PaymentList />} />
{/* 计费管理路由 */}
<Route path="/billing/rules" element={<BillingRules />} />
<Route path="/billing/accounts" element={<UserAccounts />} />
<Route path="/billing/stats" element={<BillingStats />} />
<Route path="/settings" element={<SystemSettings />} />
<Route path="*" element={<Dashboard />} />
</Routes>
</div>
</Content>
</Layout>
</Layout>
@@ -0,0 +1,338 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
Switch,
InputNumber,
Space,
Popconfirm,
message,
Card,
Row,
Col,
Statistic,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { BillingRule, CallType, TranslationType, UserType } from '../../types/billing';
import billingService from '../../services/billingService';
const { Option } = Select;
const BillingRules: React.FC = () => {
const [rules, setRules] = useState<BillingRule[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingRule, setEditingRule] = useState<BillingRule | null>(null);
const [form] = Form.useForm();
useEffect(() => {
fetchRules();
}, []);
const fetchRules = async () => {
setLoading(true);
try {
const rulesData = await billingService.getBillingRules();
setRules(rulesData);
} catch (error) {
message.error('获取计费规则失败');
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setEditingRule(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (rule: BillingRule) => {
setEditingRule(rule);
form.setFieldsValue(rule);
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await billingService.deleteBillingRule(id);
message.success('删除成功');
fetchRules();
} catch (error) {
message.error('删除失败');
}
};
const handleSubmit = async (values: any) => {
try {
if (editingRule) {
await billingService.updateBillingRule(editingRule.id, values);
message.success('更新成功');
} else {
await billingService.createBillingRule(values);
message.success('创建成功');
}
setModalVisible(false);
fetchRules();
} catch (error) {
message.error(editingRule ? '更新失败' : '创建失败');
}
};
const columns = [
{
title: '规则名称',
dataIndex: 'name',
key: 'name',
},
{
title: '通话类型',
dataIndex: 'callType',
key: 'callType',
render: (type: CallType) => {
const typeMap = {
[CallType.VOICE]: '语音通话',
[CallType.VIDEO]: '视频通话',
};
return typeMap[type];
},
},
{
title: '翻译类型',
dataIndex: 'translationType',
key: 'translationType',
render: (type: TranslationType) => {
const typeMap = {
[TranslationType.TEXT]: '文字翻译',
[TranslationType.SIGN_LANGUAGE]: '手语翻译',
[TranslationType.HUMAN_INTERPRETER]: '真人翻译',
};
return typeMap[type];
},
},
{
title: '用户类型',
dataIndex: 'userType',
key: 'userType',
render: (type: UserType) => {
const typeMap = {
[UserType.INDIVIDUAL]: '普通用户',
[UserType.ENTERPRISE]: '企业用户',
};
return typeMap[type];
},
},
{
title: '每分钟价格',
dataIndex: 'pricePerMinute',
key: 'pricePerMinute',
render: (price: number) => `¥${(price / 100).toFixed(2)}`,
},
{
title: '最低收费',
dataIndex: 'minimumCharge',
key: 'minimumCharge',
render: (charge: number) => `¥${(charge / 100).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'isActive',
key: 'isActive',
render: (isActive: boolean) => (
<span style={{ color: isActive ? '#52c41a' : '#ff4d4f' }}>
{isActive ? '启用' : '禁用'}
</span>
),
},
{
title: '操作',
key: 'action',
render: (_: any, record: BillingRule) => (
<Space size="middle">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确定要删除这个计费规则吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
const activeRules = rules.filter(rule => rule.isActive);
const inactiveRules = rules.filter(rule => !rule.isActive);
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic title="总计费规则" value={rules.length} />
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="启用规则" value={activeRules.length} />
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="禁用规则" value={inactiveRules.length} />
</Card>
</Col>
</Row>
<Card
title="计费规则管理"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
}
>
<Table
columns={columns}
dataSource={rules}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
</Card>
<Modal
title={editingRule ? '编辑计费规则' : '新增计费规则'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
name="name"
label="规则名称"
rules={[{ required: true, message: '请输入规则名称' }]}
>
<Input placeholder="请输入规则名称" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="callType"
label="通话类型"
rules={[{ required: true, message: '请选择通话类型' }]}
>
<Select placeholder="请选择通话类型">
<Option value={CallType.VOICE}></Option>
<Option value={CallType.VIDEO}></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="translationType"
label="翻译类型"
rules={[{ required: true, message: '请选择翻译类型' }]}
>
<Select placeholder="请选择翻译类型">
<Option value={TranslationType.TEXT}></Option>
<Option value={TranslationType.SIGN_LANGUAGE}></Option>
<Option value={TranslationType.HUMAN_INTERPRETER}></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="userType"
label="用户类型"
rules={[{ required: true, message: '请选择用户类型' }]}
>
<Select placeholder="请选择用户类型">
<Option value={UserType.INDIVIDUAL}></Option>
<Option value={UserType.ENTERPRISE}></Option>
</Select>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="pricePerMinute"
label="每分钟价格(分)"
rules={[{ required: true, message: '请输入每分钟价格' }]}
>
<InputNumber
min={1}
placeholder="请输入价格(分)"
style={{ width: '100%' }}
addonAfter="分"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="minimumCharge"
label="最低收费(分)"
rules={[{ required: true, message: '请输入最低收费' }]}
>
<InputNumber
min={1}
placeholder="请输入最低收费(分)"
style={{ width: '100%' }}
addonAfter="分"
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name="isActive"
label="状态"
valuePropName="checked"
initialValue={true}
>
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
{editingRule ? '更新' : '创建'}
</Button>
<Button onClick={() => setModalVisible(false)}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default BillingRules;
@@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Row,
Col,
Statistic,
Table,
DatePicker,
Select,
Space,
Button,
} from 'antd';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { BillingStats as BillingStatsType } from '../../types/billing';
import billingService from '../../services/billingService';
const { RangePicker } = DatePicker;
const { Option } = Select;
const BillingStats: React.FC = () => {
const [stats, setStats] = useState<BillingStatsType | null>(null);
const [loading, setLoading] = useState(false);
const [dateRange, setDateRange] = useState<[any, any] | null>(null);
useEffect(() => {
fetchStats();
}, [dateRange]);
const fetchStats = async () => {
setLoading(true);
try {
const startDate = dateRange?.[0]?.toDate();
const endDate = dateRange?.[1]?.toDate();
const statsData = await billingService.getBillingStats(startDate, endDate);
setStats(statsData);
} catch (error) {
console.error('获取统计数据失败:', error);
} finally {
setLoading(false);
}
};
if (!stats) {
return <div>...</div>;
}
// 饼图颜色配置
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
// 服务统计表格列
const serviceColumns = [
{
title: '服务类型',
dataIndex: 'type',
key: 'type',
},
{
title: '使用次数',
dataIndex: 'count',
key: 'count',
},
{
title: '收入金额',
dataIndex: 'revenue',
key: 'revenue',
render: (revenue: number) => `¥${(revenue / 100).toFixed(2)}`,
},
{
title: '平均单价',
key: 'avgPrice',
render: (_: any, record: any) =>
`¥${((record.revenue / record.count) / 100).toFixed(2)}`,
},
];
return (
<div>
{/* 筛选条件 */}
<Card style={{ marginBottom: 16 }}>
<Space>
<span></span>
<RangePicker
value={dateRange}
onChange={setDateRange}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={fetchStats} loading={loading}>
</Button>
</Space>
</Card>
{/* 核心指标 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="总收入"
value={stats.totalRevenue / 100}
precision={2}
prefix="¥"
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="总用户数"
value={stats.totalUsers}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="活跃用户数"
value={stats.activeUsers}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="用户活跃率"
value={(stats.activeUsers / stats.totalUsers * 100)}
precision={1}
suffix="%"
valueStyle={{ color: '#13c2c2' }}
/>
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="总通话数"
value={stats.totalCalls}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="总预约数"
value={stats.totalAppointments}
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="平均通话时长"
value={stats.averageCallDuration}
suffix="分钟"
valueStyle={{ color: '#eb2f96' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="平均通话费用"
value={stats.averageCallCost / 100}
precision={2}
prefix="¥"
valueStyle={{ color: '#f5222d' }}
/>
</Card>
</Col>
</Row>
{/* 图表区域 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
{/* 收入趋势图 */}
<Col span={16}>
<Card title="收入趋势">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={stats.revenueByDate}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis tickFormatter={(value) => `¥${(value / 100).toFixed(0)}`} />
<Tooltip
formatter={(value: any) => [`¥${(value / 100).toFixed(2)}`, '收入']}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#1890ff"
fill="#1890ff"
fillOpacity={0.3}
/>
</AreaChart>
</ResponsiveContainer>
</Card>
</Col>
{/* 服务类型分布 */}
<Col span={8}>
<Card title="服务类型分布">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={stats.topServices}
cx="50%"
cy="50%"
labelLine={false}
label={({ type, percent }) => `${type} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="count"
>
{stats.topServices.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<Row gutter={16}>
{/* 服务收入对比 */}
<Col span={12}>
<Card title="服务收入对比">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats.topServices}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="type" />
<YAxis tickFormatter={(value) => `¥${(value / 100).toFixed(0)}`} />
<Tooltip
formatter={(value: any) => [`¥${(value / 100).toFixed(2)}`, '收入']}
/>
<Bar dataKey="revenue" fill="#52c41a" />
</BarChart>
</ResponsiveContainer>
</Card>
</Col>
{/* 服务使用次数对比 */}
<Col span={12}>
<Card title="服务使用次数对比">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats.topServices}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="type" />
<YAxis />
<Tooltip formatter={(value: any) => [value, '次数']} />
<Bar dataKey="count" fill="#1890ff" />
</BarChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
{/* 详细数据表格 */}
<Card title="服务详细统计" style={{ marginTop: 16 }}>
<Table
columns={serviceColumns}
dataSource={stats.topServices}
rowKey="type"
pagination={false}
/>
</Card>
</div>
);
};
export default BillingStats;
@@ -0,0 +1,318 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
InputNumber,
Space,
message,
Card,
Row,
Col,
Statistic,
Tag,
Tabs,
} from 'antd';
import { WalletOutlined, PlusOutlined, MinusOutlined, StopOutlined } from '@ant-design/icons';
import { UserAccount, UserType, RechargeRecord, ConsumptionRecord } from '../../types/billing';
import billingService from '../../services/billingService';
const { TabPane } = Tabs;
const UserAccounts: React.FC = () => {
const [accounts, setAccounts] = useState<UserAccount[]>([]);
const [rechargeRecords, setRechargeRecords] = useState<RechargeRecord[]>([]);
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [modalType, setModalType] = useState<'recharge' | 'deduct' | 'freeze' | 'unfreeze'>('recharge');
const [selectedAccount, setSelectedAccount] = useState<UserAccount | null>(null);
const [form] = Form.useForm();
useEffect(() => {
fetchAccounts();
fetchRechargeRecords();
fetchConsumptionRecords();
}, []);
const fetchAccounts = async () => {
setLoading(true);
try {
const { accounts: accountsData } = await billingService.getUserAccounts();
setAccounts(accountsData);
} catch (error) {
message.error('获取用户账户失败');
} finally {
setLoading(false);
}
};
const fetchRechargeRecords = async () => {
try {
const { records } = await billingService.getRechargeRecords();
setRechargeRecords(records);
} catch (error) {
message.error('获取充值记录失败');
}
};
const fetchConsumptionRecords = async () => {
try {
const { records } = await billingService.getConsumptionRecords();
setConsumptionRecords(records);
} catch (error) {
message.error('获取消费记录失败');
}
};
const handleBalanceOperation = (account: UserAccount, type: 'recharge' | 'deduct' | 'freeze' | 'unfreeze') => {
setSelectedAccount(account);
setModalType(type);
form.resetFields();
setModalVisible(true);
};
const handleSubmit = async (values: any) => {
if (!selectedAccount) return;
try {
const { amount, reason } = values;
switch (modalType) {
case 'recharge':
await billingService.updateUserBalance(selectedAccount.userId, amount, reason || '管理员充值');
message.success('充值成功');
break;
case 'deduct':
await billingService.updateUserBalance(selectedAccount.userId, -amount, reason || '管理员扣费');
message.success('扣费成功');
break;
case 'freeze':
await billingService.freezeUserBalance(selectedAccount.userId, amount);
message.success('冻结成功');
break;
case 'unfreeze':
await billingService.unfreezeUserBalance(selectedAccount.userId, amount);
message.success('解冻成功');
break;
}
setModalVisible(false);
fetchAccounts();
} catch (error: any) {
message.error(error.message || '操作失败');
}
};
const accountColumns = [
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
},
{
title: '用户类型',
dataIndex: 'userType',
key: 'userType',
render: (type: UserType) => {
const typeMap = {
[UserType.INDIVIDUAL]: { text: '普通用户', color: 'blue' },
[UserType.ENTERPRISE]: { text: '企业用户', color: 'gold' },
};
const config = typeMap[type];
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '账户余额',
dataIndex: 'balance',
key: 'balance',
render: (balance: number) => (
<span style={{ color: balance > 0 ? '#52c41a' : '#ff4d4f' }}>
¥{(balance / 100).toFixed(2)}
</span>
),
},
{
title: '冻结余额',
dataIndex: 'frozenBalance',
key: 'frozenBalance',
render: (balance: number) => `¥${(balance / 100).toFixed(2)}`,
},
{
title: '累计充值',
dataIndex: 'totalRecharge',
key: 'totalRecharge',
render: (amount: number) => `¥${(amount / 100).toFixed(2)}`,
},
{
title: '累计消费',
dataIndex: 'totalConsumption',
key: 'totalConsumption',
render: (amount: number) => `¥${(amount / 100).toFixed(2)}`,
},
{
title: '操作',
key: 'action',
render: (_: any, record: UserAccount) => (
<Space size="small">
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => handleBalanceOperation(record, 'recharge')}
>
</Button>
<Button
type="link"
danger
icon={<MinusOutlined />}
onClick={() => handleBalanceOperation(record, 'deduct')}
>
</Button>
<Button
type="link"
icon={<StopOutlined />}
onClick={() => handleBalanceOperation(record, 'freeze')}
>
</Button>
</Space>
),
},
];
const totalBalance = accounts.reduce((sum, account) => sum + account.balance, 0);
const totalFrozenBalance = accounts.reduce((sum, account) => sum + account.frozenBalance, 0);
const getModalTitle = () => {
const titles = {
recharge: '用户充值',
deduct: '用户扣费',
freeze: '冻结余额',
unfreeze: '解冻余额',
};
return titles[modalType];
};
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic
title="总账户余额"
value={totalBalance / 100}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="总冻结余额"
value={totalFrozenBalance / 100}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="用户数量"
value={accounts.length}
/>
</Card>
</Col>
</Row>
<Card>
<Tabs defaultActiveKey="accounts">
<TabPane tab="用户账户" key="accounts">
<Table
columns={accountColumns}
dataSource={accounts}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
</TabPane>
</Tabs>
</Card>
<Modal
title={getModalTitle()}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={500}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item label="用户ID">
<Input value={selectedAccount?.userId} disabled />
</Form.Item>
<Form.Item label="当前余额">
<Input
value={`¥${((selectedAccount?.balance || 0) / 100).toFixed(2)}`}
disabled
/>
</Form.Item>
<Form.Item
name="amount"
label="金额(分)"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber
min={1}
placeholder="请输入金额(分)"
style={{ width: '100%' }}
addonAfter="分"
/>
</Form.Item>
{(modalType === 'recharge' || modalType === 'deduct') && (
<Form.Item
name="reason"
label="操作原因"
rules={[{ required: true, message: '请输入操作原因' }]}
>
<Input.TextArea
placeholder="请输入操作原因"
rows={3}
/>
</Form.Item>
)}
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
{getModalTitle()}
</Button>
<Button onClick={() => setModalVisible(false)}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserAccounts;
@@ -0,0 +1,3 @@
export { default as BillingRules } from './BillingRules';
export { default as UserAccounts } from './UserAccounts';
export { default as BillingStats } from './BillingStats';
@@ -0,0 +1,440 @@
import {
BillingRule,
UserAccount,
CallRecord,
RechargeRecord,
ConsumptionRecord,
BillingStats,
CallType,
TranslationType,
UserType,
BILLING_CONFIG,
} from '../types/billing';
class BillingService {
private static instance: BillingService;
public static getInstance(): BillingService {
if (!BillingService.instance) {
BillingService.instance = new BillingService();
}
return BillingService.instance;
}
// 计费规则管理
async getBillingRules(): Promise<BillingRule[]> {
// TODO: 替换为实际API调用
return [
{
id: '1',
name: '语音文字翻译',
callType: CallType.VOICE,
translationType: TranslationType.TEXT,
pricePerMinute: 50,
minimumCharge: 50,
userType: UserType.INDIVIDUAL,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
name: '视频手语翻译',
callType: CallType.VIDEO,
translationType: TranslationType.SIGN_LANGUAGE,
pricePerMinute: 100,
minimumCharge: 100,
userType: UserType.INDIVIDUAL,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '3',
name: '视频真人翻译',
callType: CallType.VIDEO,
translationType: TranslationType.HUMAN_INTERPRETER,
pricePerMinute: 200,
minimumCharge: 200,
userType: UserType.INDIVIDUAL,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
}
async createBillingRule(rule: Omit<BillingRule, 'id' | 'createdAt' | 'updatedAt'>): Promise<BillingRule> {
// TODO: 替换为实际API调用
const newRule: BillingRule = {
...rule,
id: Date.now().toString(),
createdAt: new Date(),
updatedAt: new Date(),
};
return newRule;
}
async updateBillingRule(id: string, updates: Partial<BillingRule>): Promise<BillingRule> {
// TODO: 替换为实际API调用
const existingRule = await this.getBillingRuleById(id);
return {
...existingRule,
...updates,
updatedAt: new Date(),
};
}
async deleteBillingRule(id: string): Promise<void> {
// TODO: 替换为实际API调用
console.log('删除计费规则:', id);
}
async getBillingRuleById(id: string): Promise<BillingRule> {
// TODO: 替换为实际API调用
const rules = await this.getBillingRules();
const rule = rules.find(r => r.id === id);
if (!rule) {
throw new Error('计费规则未找到');
}
return rule;
}
// 用户账户管理
async getUserAccounts(page: number = 1, pageSize: number = 10): Promise<{
accounts: UserAccount[];
total: number;
}> {
// TODO: 替换为实际API调用
const mockAccounts: UserAccount[] = [
{
id: '1',
userId: 'user1',
userType: UserType.INDIVIDUAL,
balance: 5000,
frozenBalance: 0,
totalRecharge: 10000,
totalConsumption: 5000,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
userId: 'user2',
userType: UserType.ENTERPRISE,
balance: 50000,
frozenBalance: 5000,
creditLimit: 100000,
totalRecharge: 100000,
totalConsumption: 50000,
createdAt: new Date(),
updatedAt: new Date(),
},
];
return {
accounts: mockAccounts,
total: mockAccounts.length,
};
}
async getUserAccountById(userId: string): Promise<UserAccount> {
// TODO: 替换为实际API调用
const { accounts } = await this.getUserAccounts();
const account = accounts.find(a => a.userId === userId);
if (!account) {
throw new Error('用户账户未找到');
}
return account;
}
async updateUserBalance(userId: string, amount: number, reason: string): Promise<UserAccount> {
// TODO: 替换为实际API调用
const account = await this.getUserAccountById(userId);
account.balance += amount;
account.updatedAt = new Date();
// 记录消费记录
if (amount !== 0) {
await this.createConsumptionRecord({
userId,
relatedType: 'call',
relatedId: 'admin-adjustment',
amount: Math.abs(amount),
balanceBefore: account.balance - amount,
balanceAfter: account.balance,
description: reason,
});
}
return account;
}
async freezeUserBalance(userId: string, amount: number): Promise<UserAccount> {
// TODO: 替换为实际API调用
const account = await this.getUserAccountById(userId);
if (account.balance < amount) {
throw new Error('余额不足');
}
account.balance -= amount;
account.frozenBalance += amount;
account.updatedAt = new Date();
return account;
}
async unfreezeUserBalance(userId: string, amount: number): Promise<UserAccount> {
// TODO: 替换为实际API调用
const account = await this.getUserAccountById(userId);
if (account.frozenBalance < amount) {
throw new Error('冻结余额不足');
}
account.frozenBalance -= amount;
account.balance += amount;
account.updatedAt = new Date();
return account;
}
// 通话记录管理
async getCallRecords(
page: number = 1,
pageSize: number = 10,
filters?: {
userId?: string;
status?: string;
startDate?: Date;
endDate?: Date;
}
): Promise<{
records: CallRecord[];
total: number;
}> {
// TODO: 替换为实际API调用
const mockRecords: CallRecord[] = [
{
id: '1',
userId: 'user1',
callType: CallType.VOICE,
translationType: TranslationType.TEXT,
startTime: new Date(Date.now() - 3600000),
endTime: new Date(),
duration: 60,
cost: 3000,
status: 'completed',
billingDetails: {
baseRate: 50,
totalMinutes: 60,
totalCost: 3000,
},
createdAt: new Date(),
qualityScore: 4.5,
userRating: 5,
userFeedback: '翻译质量很好',
},
];
return {
records: mockRecords,
total: mockRecords.length,
};
}
async getCallRecordById(id: string): Promise<CallRecord> {
// TODO: 替换为实际API调用
const { records } = await this.getCallRecords();
const record = records.find(r => r.id === id);
if (!record) {
throw new Error('通话记录未找到');
}
return record;
}
// 充值记录管理
async getRechargeRecords(
page: number = 1,
pageSize: number = 10,
filters?: {
userId?: string;
status?: string;
startDate?: Date;
endDate?: Date;
}
): Promise<{
records: RechargeRecord[];
total: number;
}> {
// TODO: 替换为实际API调用
const mockRecords: RechargeRecord[] = [
{
id: '1',
userId: 'user1',
amount: 10000,
bonus: 500,
paymentMethod: 'wechat',
status: 'completed',
transactionId: 'tx_123456',
createdAt: new Date(),
completedAt: new Date(),
},
];
return {
records: mockRecords,
total: mockRecords.length,
};
}
async processRecharge(
userId: string,
amount: number,
paymentMethod: string,
transactionId: string
): Promise<RechargeRecord> {
// TODO: 替换为实际API调用
const bonus = this.calculateRechargeBonus(amount);
const record: RechargeRecord = {
id: Date.now().toString(),
userId,
amount,
bonus,
paymentMethod,
status: 'completed',
transactionId,
createdAt: new Date(),
completedAt: new Date(),
};
// 更新用户余额
await this.updateUserBalance(userId, amount + bonus, `充值 ${amount/100}元,赠送 ${bonus/100}`);
return record;
}
// 消费记录管理
async getConsumptionRecords(
page: number = 1,
pageSize: number = 10,
filters?: {
userId?: string;
relatedType?: string;
startDate?: Date;
endDate?: Date;
}
): Promise<{
records: ConsumptionRecord[];
total: number;
}> {
// TODO: 替换为实际API调用
const mockRecords: ConsumptionRecord[] = [
{
id: '1',
userId: 'user1',
relatedType: 'call',
relatedId: 'call_123',
amount: 3000,
balanceBefore: 8000,
balanceAfter: 5000,
description: '语音文字翻译通话费用',
createdAt: new Date(),
},
];
return {
records: mockRecords,
total: mockRecords.length,
};
}
async createConsumptionRecord(
record: Omit<ConsumptionRecord, 'id' | 'createdAt'>
): Promise<ConsumptionRecord> {
// TODO: 替换为实际API调用
return {
...record,
id: Date.now().toString(),
createdAt: new Date(),
};
}
// 统计数据
async getBillingStats(
startDate?: Date,
endDate?: Date
): Promise<BillingStats> {
// TODO: 替换为实际API调用
return {
totalRevenue: 1000000, // 10000元
totalUsers: 1500,
activeUsers: 800,
totalCalls: 5000,
totalAppointments: 1200,
averageCallDuration: 45,
averageCallCost: 2000, // 20元
topServices: [
{ type: '语音文字翻译', count: 3000, revenue: 600000 },
{ type: '视频手语翻译', count: 1500, revenue: 300000 },
{ type: '视频真人翻译', count: 500, revenue: 100000 },
],
revenueByDate: [
{ date: '2024-01-01', revenue: 50000 },
{ date: '2024-01-02', revenue: 60000 },
{ date: '2024-01-03', revenue: 55000 },
],
};
}
// 工具方法
private calculateRechargeBonus(amount: number): number {
for (const rule of BILLING_CONFIG.RECHARGE_BONUS_RULES) {
if (amount >= rule.minAmount) {
return Math.floor(amount * rule.bonusPercentage / 100);
}
}
return 0;
}
// 计算通话费用
calculateCallCost(
callType: CallType,
translationType: TranslationType,
duration: number,
userType: UserType = UserType.INDIVIDUAL
): number {
// TODO: 根据计费规则计算实际费用
const baseRate = BILLING_CONFIG.DEFAULT_RATES[callType]?.[translationType] || 100;
return Math.max(baseRate, baseRate * Math.ceil(duration));
}
// 检查用户余额是否充足
async checkUserBalance(userId: string, requiredAmount: number): Promise<boolean> {
const account = await this.getUserAccountById(userId);
return account.balance >= requiredAmount;
}
// 扣费
async deductBalance(
userId: string,
amount: number,
relatedType: 'call' | 'appointment' | 'document',
relatedId: string,
description: string
): Promise<void> {
const account = await this.getUserAccountById(userId);
if (account.balance < amount) {
throw new Error('余额不足');
}
await this.updateUserBalance(userId, -amount, description);
await this.createConsumptionRecord({
userId,
relatedType,
relatedId,
amount,
balanceBefore: account.balance,
balanceAfter: account.balance - amount,
description,
});
}
}
export default BillingService.getInstance();
+189
View File
@@ -0,0 +1,189 @@
// 用户类型
export enum UserType {
INDIVIDUAL = 'individual', // 普通用户
ENTERPRISE = 'enterprise', // 企业用户
}
// 通话类型
export enum CallType {
VOICE = 'voice', // 语音通话
VIDEO = 'video', // 视频通话
}
// 翻译类型
export enum TranslationType {
TEXT = 'text', // 文字翻译
SIGN_LANGUAGE = 'sign_language', // 手语翻译
HUMAN_INTERPRETER = 'human_interpreter', // 真人翻译
}
// 计费规则
export interface BillingRule {
id: string;
name: string;
callType: CallType;
translationType: TranslationType;
pricePerMinute: number; // 每分钟价格(分)
minimumCharge: number; // 最低收费(分)
userType: UserType;
isActive: boolean; // 是否启用
createdAt: Date;
updatedAt: Date;
}
// 用户账户信息
export interface UserAccount {
id: string;
userId: string;
userType: UserType;
balance: number; // 余额(分)
frozenBalance: number; // 冻结余额(分)
enterpriseContractId?: string; // 企业合同ID
creditLimit?: number; // 信用额度(分)
totalRecharge: number; // 累计充值(分)
totalConsumption: number; // 累计消费(分)
createdAt: Date;
updatedAt: Date;
}
// 预约信息
export interface Appointment {
id: string;
userId: string;
title: string;
description?: string;
scheduledTime: Date;
duration: number; // 预计时长(分钟)
callType: CallType;
translationType: TranslationType;
interpreterIds?: string[]; // 翻译员ID列表
estimatedCost: number; // 预估费用(分)
actualCost?: number; // 实际费用(分)
status: 'scheduled' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled';
createdAt: Date;
updatedAt: Date;
// 管理员字段
adminNotes?: string;
cancelReason?: string;
}
// 翻译员信息
export interface Interpreter {
id: string;
name: string;
avatar?: string;
languages: string[]; // 支持的语言
specialties: string[]; // 专业领域
rating: number; // 评分
pricePerMinute: number; // 每分钟价格(分)
availability: {
[key: string]: boolean; // 日期可用性
};
isOnline: boolean;
totalCalls: number; // 总通话次数
totalEarnings: number; // 总收入(分)
status: 'active' | 'inactive' | 'suspended';
createdAt: Date;
updatedAt: Date;
}
// 通话记录
export interface CallRecord {
id: string;
userId: string;
appointmentId?: string;
callType: CallType;
translationType: TranslationType;
interpreterIds?: string[];
startTime: Date;
endTime?: Date;
duration: number; // 实际时长(分钟)
cost: number; // 实际费用(分)
status: 'in_progress' | 'completed' | 'failed';
billingDetails: {
baseRate: number;
interpreterRate?: number;
totalMinutes: number;
totalCost: number;
};
createdAt: Date;
// 管理员字段
adminNotes?: string;
qualityScore?: number;
userRating?: number;
userFeedback?: string;
}
// 充值记录
export interface RechargeRecord {
id: string;
userId: string;
amount: number; // 充值金额(分)
bonus: number; // 赠送金额(分)
paymentMethod: string;
status: 'pending' | 'completed' | 'failed';
transactionId?: string;
createdAt: Date;
completedAt?: Date;
// 管理员字段
adminNotes?: string;
refundAmount?: number;
refundReason?: string;
}
// 消费记录
export interface ConsumptionRecord {
id: string;
userId: string;
relatedType: 'call' | 'appointment' | 'document';
relatedId: string;
amount: number; // 消费金额(分)
balanceBefore: number; // 消费前余额(分)
balanceAfter: number; // 消费后余额(分)
description: string;
createdAt: Date;
}
// 计费统计
export interface BillingStats {
totalRevenue: number; // 总收入(分)
totalUsers: number; // 总用户数
activeUsers: number; // 活跃用户数
totalCalls: number; // 总通话数
totalAppointments: number; // 总预约数
averageCallDuration: number; // 平均通话时长(分钟)
averageCallCost: number; // 平均通话费用(分)
topServices: Array<{
type: string;
count: number;
revenue: number;
}>;
revenueByDate: Array<{
date: string;
revenue: number;
}>;
}
// 计费配置
export const BILLING_CONFIG = {
// 默认计费规则
DEFAULT_RATES: {
[CallType.VOICE]: {
[TranslationType.TEXT]: 50, // 0.5元/分钟
},
[CallType.VIDEO]: {
[TranslationType.SIGN_LANGUAGE]: 100, // 1元/分钟
[TranslationType.HUMAN_INTERPRETER]: 200, // 2元/分钟
},
} as Record<CallType, Partial<Record<TranslationType, number>>>,
// 低余额警告阈值(5分钟费用)
LOW_BALANCE_THRESHOLD_MINUTES: 5,
// 最低余额阈值(1分钟费用)
MINIMUM_BALANCE_THRESHOLD_MINUTES: 1,
// 充值赠送规则
RECHARGE_BONUS_RULES: [
{ minAmount: 10000, bonusPercentage: 5 }, // 100元以上送5%
{ minAmount: 20000, bonusPercentage: 10 }, // 200元以上送10%
{ minAmount: 50000, bonusPercentage: 15 }, // 500元以上送15%
],
};