通话逻辑调整
This commit is contained in:
Generated
+413
-7
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
@@ -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%
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user