Compare commits
219 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d11f01115 | ||
|
|
1deebf3f5c | ||
|
|
ae1120f1e3 | ||
|
|
b597ed2275 | ||
|
|
1ecfacce49 | ||
|
|
b20f00be53 | ||
|
|
168fff56c1 | ||
|
|
689705ef70 | ||
|
|
6629a9ad14 | ||
|
|
af79d1e860 | ||
|
|
38379fac65 | ||
|
|
7534dd79fa | ||
|
|
f956fc949b | ||
|
|
442be3b02c | ||
|
|
208c25b2f7 | ||
|
|
dd4b43c10e | ||
|
|
519a9b1d1b | ||
|
|
f0c278a35f | ||
|
|
ab5885f2a0 | ||
|
|
9bc1d7d7bf | ||
|
|
4d8fd96190 | ||
|
|
7f7a5357a0 | ||
|
|
28adf38f2f | ||
|
|
b2abe9f570 | ||
|
|
5aa8657640 | ||
|
|
44dbe20b03 | ||
|
|
3a0621aab8 | ||
|
|
28f4479d3c | ||
|
|
e356ae67c7 | ||
|
|
6b31889261 | ||
|
|
16aec83f02 | ||
|
|
9fe0f9f677 | ||
|
|
ee4dcb1b8b | ||
|
|
5dfaba943b | ||
|
|
bac66de046 | ||
|
|
9b8f3b1b2b | ||
|
|
56d5f0b5a3 | ||
|
|
51a731ea68 | ||
|
|
002f03e037 | ||
|
|
a250819d87 | ||
|
|
135e58f1e2 | ||
|
|
58b6cd2340 | ||
|
|
b21f40942e | ||
|
|
dd9fa3d0f2 | ||
|
|
430b8eee91 | ||
|
|
cec64b889b | ||
|
|
443ef6ad2d | ||
|
|
3e8b607948 | ||
|
|
9eeaaa70c4 | ||
|
|
80ae205cb3 | ||
|
|
cd38e0eedd | ||
|
|
66d217d5f1 | ||
|
|
2b128195bf | ||
|
|
e469537ac5 | ||
|
|
b2e84a2259 | ||
|
|
228d59402f | ||
|
|
ea2dd8cab2 | ||
|
|
efcbb06eb2 | ||
|
|
61e720c247 | ||
|
|
88418386a4 | ||
|
|
f096e07ec8 | ||
|
|
046b00404e | ||
|
|
9bb6390f9a | ||
|
|
cb612746a6 | ||
|
|
8beb2cb6fe | ||
|
|
aabd8037a6 | ||
|
|
408c6a2029 | ||
|
|
ba0ebcf273 | ||
|
|
0457528dd0 | ||
|
|
e6037ccfd5 | ||
|
|
ce1c434ea8 | ||
|
|
b4c813311b | ||
|
|
08d2a811b7 | ||
|
|
256afaee12 | ||
|
|
198c62081b | ||
|
|
53b679aeb6 | ||
|
|
64358df1c1 | ||
|
|
b0d9481cdb | ||
|
|
db0985504a | ||
|
|
82f9b9371a | ||
|
|
d9ee7e9402 | ||
|
|
b609eeb0eb | ||
|
|
203e8c8eaf | ||
|
|
2c90f6f33c | ||
|
|
88bc7ef3cd | ||
|
|
5ae0d997d9 | ||
|
|
879396e91c | ||
|
|
b8754f6174 | ||
|
|
302617cd81 | ||
|
|
79fd133821 | ||
|
|
4f2b34522f | ||
|
|
853a7f32d2 | ||
|
|
eea18cc0c7 | ||
|
|
0e173668c5 | ||
|
|
abdcff813c | ||
|
|
0ac84bd033 | ||
|
|
4c12619f0e | ||
|
|
dc1f194bcc | ||
|
|
7a89b80c8f | ||
|
|
7a4f40de54 | ||
|
|
988d7fcdd3 | ||
|
|
236ef09538 | ||
|
|
d914f097c0 | ||
|
|
c37e5aca35 | ||
|
|
5e198d1021 | ||
|
|
cc91552c36 | ||
|
|
f208bbdb9a | ||
|
|
20f567da37 | ||
|
|
9b45ebea0e | ||
|
|
7a0ae54705 | ||
|
|
3f5a2189ce | ||
|
|
36d6f94177 | ||
|
|
c046bdab30 | ||
|
|
0c8f1d6419 | ||
|
|
f8810f5199 | ||
|
|
bc6421b1af | ||
|
|
2f00ee176b | ||
|
|
44da1feed3 | ||
|
|
66c8623138 | ||
|
|
e12fbad859 | ||
|
|
183df2cac3 | ||
|
|
7f1fda40dc | ||
|
|
d42a2df286 | ||
|
|
1a345fff79 | ||
|
|
7724d60a05 | ||
|
|
1cb7903e37 | ||
|
|
d8183909ca | ||
|
|
7f7d5d5c39 | ||
|
|
72cf3fd81c | ||
|
|
65167be7f8 | ||
|
|
7dda34a779 | ||
|
|
bde1d36005 | ||
|
|
631e8d00e2 | ||
|
|
3720987997 | ||
|
|
8e4ce2aee2 | ||
|
|
08fc18da86 | ||
|
|
b2760cde0e | ||
|
|
61926503cf | ||
|
|
84a51b8f91 | ||
|
|
4f8a69a511 | ||
|
|
4537305dfc | ||
|
|
be901a92f6 | ||
|
|
1067a15916 | ||
|
|
2a41d88ce3 | ||
|
|
80027e8fa3 | ||
|
|
df893296ba | ||
|
|
77e9f763cd | ||
|
|
c531957ce8 | ||
|
|
257ca5014b | ||
|
|
1c3e567b9a | ||
|
|
4aacb7bcb6 | ||
|
|
e7fef596d4 | ||
|
|
5d95ca6f53 | ||
|
|
3cd6bf9dba | ||
|
|
67a4bd6e3a | ||
|
|
c33c62aa6d | ||
|
|
581e09d45b | ||
|
|
688a200999 | ||
|
|
cc89df7957 | ||
|
|
198e0434b3 | ||
|
|
55fe2b46df | ||
|
|
c7e934f23d | ||
|
|
7752dd136f | ||
|
|
af2f32da4a | ||
|
|
81859dfacb | ||
|
|
3e145ca123 | ||
|
|
c9a5d3091f | ||
|
|
4374ad5036 | ||
|
|
1ca2ca7058 | ||
|
|
9fe15b1bec | ||
|
|
a557319b3e | ||
|
|
f06eb0e9d7 | ||
|
|
1be091522e | ||
|
|
63df35b79c | ||
|
|
2697da15aa | ||
|
|
ced0f993de | ||
|
|
55629a8ce4 | ||
|
|
923c6a5904 | ||
|
|
b63e87d0cf | ||
|
|
96caa71f03 | ||
|
|
b9b1877e04 | ||
|
|
81daa8cfdc | ||
|
|
903ea005e3 | ||
|
|
580e9f4f73 | ||
|
|
51e08d1b2a | ||
|
|
3708f5a61e | ||
|
|
58377b4670 | ||
|
|
5bb5aa3d5a | ||
|
|
71def21369 | ||
|
|
1ea565fbdf | ||
|
|
56a9a015f1 | ||
|
|
0708b5a134 | ||
|
|
242065d298 | ||
|
|
b5d6214521 | ||
|
|
9c5bccb23c | ||
|
|
5f5b567749 | ||
|
|
354d6c4f62 | ||
|
|
2c37314c4f | ||
|
|
1dbdee155e | ||
|
|
2939b63998 | ||
|
|
ff7097f2d9 | ||
|
|
33cd4de3c5 | ||
|
|
0bc6c3a22a | ||
|
|
44edfe7a81 | ||
|
|
d3fae3bbd0 | ||
|
|
11a4ec8c67 | ||
|
|
75aab08a72 | ||
|
|
2cc2184002 | ||
|
|
57c398f537 | ||
|
|
7d9f94984c | ||
|
|
8659049ae9 | ||
|
|
1ca5b30c15 | ||
|
|
b9bc0a71a9 | ||
|
|
13ae8bd76f | ||
|
|
31fdabaa15 | ||
|
|
43e9930b45 | ||
|
|
4baa82449c | ||
|
|
5a5160b92d | ||
|
|
3e78122659 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,3 +13,8 @@ SuperAdmin/.specstory/
|
||||
Cunkebao/dist
|
||||
Touchkebao/.specstory/
|
||||
Serverruntime/
|
||||
Moncter/提示词/
|
||||
*.log
|
||||
*.code-workspace
|
||||
项目整体分析报告.md
|
||||
Server/docs/traffic_pool_design.md
|
||||
|
||||
4
Cunkebao/.gitignore
vendored
4
Cunkebao/.gitignore
vendored
@@ -5,3 +5,7 @@ yarn.lock
|
||||
.env
|
||||
.DS_Store
|
||||
dist/*
|
||||
.cursorindexingignore
|
||||
*.zip
|
||||
.idea/
|
||||
.next/
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"antd-mobile": "^5.39.1",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.6.7",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -21,6 +22,7 @@
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
|
||||
33
Cunkebao/pnpm-lock.yaml
generated
33
Cunkebao/pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.6.7
|
||||
version: 1.11.0
|
||||
crypto-js:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
@@ -54,6 +57,9 @@ importers:
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.7(@types/react@19.1.10)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
|
||||
devDependencies:
|
||||
'@types/crypto-js':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
'@types/node':
|
||||
specifier: ^24.0.14
|
||||
version: 24.2.1
|
||||
@@ -489,36 +495,42 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
@@ -669,56 +681,67 @@ packages:
|
||||
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
||||
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
||||
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
||||
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.46.2':
|
||||
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
||||
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
||||
@@ -747,6 +770,9 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/crypto-js@4.2.2':
|
||||
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -1016,6 +1042,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
@@ -2958,6 +2987,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.2
|
||||
|
||||
'@types/crypto-js@4.2.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/node@24.2.1':
|
||||
@@ -3357,6 +3388,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypto-js@4.2.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getContentLibraryList(params: any) {
|
||||
return request("/v1/content/library/list", params, "GET");
|
||||
return request("/v1/content/library/list", { ...params, formType: 0 }, "GET");
|
||||
}
|
||||
|
||||
@@ -26,4 +26,5 @@ export interface DeviceSelectionProps {
|
||||
showSelectedList?: boolean; // 新增
|
||||
readonly?: boolean; // 新增
|
||||
deviceGroups?: any[]; // 传递设备组数据
|
||||
singleSelect?: boolean; // 新增,是否单选模式
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
singleSelect = false,
|
||||
}) => {
|
||||
// 弹窗控制
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
@@ -37,6 +38,9 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
if (singleSelect && selectedOptions.length > 0) {
|
||||
return selectedOptions[0].memo || selectedOptions[0].wechatId || "已选择设备";
|
||||
}
|
||||
return `已选择 ${selectedOptions.length} 个设备`;
|
||||
};
|
||||
|
||||
@@ -179,6 +183,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||
onClose={() => setRealVisible(false)}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={onSelect}
|
||||
singleSelect={singleSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ interface SelectionPopupProps {
|
||||
onClose: () => void;
|
||||
selectedOptions: DeviceSelectionItem[];
|
||||
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||
singleSelect?: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -21,6 +22,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
onClose,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
singleSelect = false,
|
||||
}) => {
|
||||
// 设备数据
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
@@ -110,6 +112,15 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
||||
if (singleSelect) {
|
||||
// 单选模式:如果已选中,则取消选择;否则替换为当前设备
|
||||
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||
setTempSelectedOptions([]);
|
||||
} else {
|
||||
setTempSelectedOptions([device]);
|
||||
}
|
||||
} else {
|
||||
// 多选模式:原有的逻辑
|
||||
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||
setTempSelectedOptions(
|
||||
tempSelectedOptions.filter(v => v.id !== device.id),
|
||||
@@ -117,6 +128,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
} else {
|
||||
const newSelectedOptions = [...tempSelectedOptions, device];
|
||||
setTempSelectedOptions(newSelectedOptions);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,6 +191,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={tempSelectedOptions.length}
|
||||
singleSelect={singleSelect}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={onClose}
|
||||
onConfirm={() => {
|
||||
@@ -187,7 +200,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
isAllSelected={isCurrentPageAllSelected}
|
||||
onSelectAll={handleSelectAllCurrentPage}
|
||||
onSelectAll={singleSelect ? undefined : handleSelectAllCurrentPage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -95,9 +95,9 @@ export default function FriendSelection({
|
||||
{(selectedOptions || []).map(friend => (
|
||||
<div key={friend.id} className={style.selectedListRow}>
|
||||
<div className={style.selectedListRowContent}>
|
||||
<Avatar src={friend.avatar} />
|
||||
<Avatar src={friend.avatar || friend.friendAvatar} />
|
||||
<div className={style.selectedListRowContentText}>
|
||||
<div>{friend.nickname}</div>
|
||||
<div>{friend.nickname || friend.friendName}</div>
|
||||
<div>{friend.wechatId}</div>
|
||||
</div>
|
||||
{!readonly && (
|
||||
|
||||
@@ -82,10 +82,15 @@ export default function GroupSelection({
|
||||
{selectedOptions.map(group => (
|
||||
<div key={group.id} className={style.selectedListRow}>
|
||||
<div className={style.selectedListRowContent}>
|
||||
<Avatar src={group.avatar} />
|
||||
<Avatar src={group.groupAvatar || group.avatar} />
|
||||
<div className={style.selectedListRowContentText}>
|
||||
<div>{group.name}</div>
|
||||
<div>{group.groupName || group.name}</div>
|
||||
{group.nickName && (
|
||||
<div style={{ fontSize: 12, color: "#666" }}>归属:{group.nickName}</div>
|
||||
)}
|
||||
{!group.nickName && group.chatroomId && (
|
||||
<div>{group.chatroomId}</div>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
|
||||
@@ -142,7 +142,11 @@ export default function SelectionPopup({
|
||||
// 复制一份selectedOptions到临时变量
|
||||
setTempSelectedOptions([...selectedOptions]);
|
||||
fetchGroups(1, "");
|
||||
} else {
|
||||
// 弹窗关闭时重置状态
|
||||
setTempSelectedOptions([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
|
||||
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedGroupsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.groupCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.groupInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupAvatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupDetails {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupName {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupId {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deleteGroupBtn {
|
||||
color: #ff4d4f;
|
||||
font-size: 18px;
|
||||
padding: 4px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.membersSection {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.membersLabel {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.membersList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memberItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.memberAvatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.removeMemberBtn {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 50%;
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.memberName {
|
||||
font-size: 12px;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addMemberBtn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 50%;
|
||||
background: #fafafa;
|
||||
color: #999;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #f0f0f0;
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.memberSelectionPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.popupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.popupTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
color: #1677ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.searchInputWrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clearSearchBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
padding: 0;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.searchBtn {
|
||||
min-width: 60px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.memberList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.memberListItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
.memberListItemName {
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.memberListItemAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.memberListItemName {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
color: #1677ff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
438
Cunkebao/src/components/GroupSelectionWithMembers/index.tsx
Normal file
438
Cunkebao/src/components/GroupSelectionWithMembers/index.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SearchOutlined, DeleteOutlined, PlusOutlined, CloseOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Popup } from "antd-mobile";
|
||||
import { Avatar } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import GroupSelection from "../GroupSelection";
|
||||
import { GroupSelectionItem } from "../GroupSelection/data";
|
||||
import request from "@/api/request";
|
||||
|
||||
// 群成员接口
|
||||
export interface GroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
}
|
||||
|
||||
// 带成员的群选项
|
||||
export interface GroupWithMembers extends GroupSelectionItem {
|
||||
members?: GroupMember[];
|
||||
groupId?: string; // 用于关联成员和群
|
||||
}
|
||||
|
||||
interface GroupSelectionWithMembersProps {
|
||||
selectedGroups: GroupWithMembers[];
|
||||
onSelect: (groups: GroupWithMembers[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
// 获取群成员列表
|
||||
const getGroupMembers = async (
|
||||
groupId: string,
|
||||
page: number = 1,
|
||||
limit: number = 100,
|
||||
keyword: string = "",
|
||||
): Promise<GroupMember[]> => {
|
||||
try {
|
||||
const params: any = {
|
||||
page,
|
||||
limit,
|
||||
groupId,
|
||||
};
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
const response = await request("/v1/kefu/wechatChatroom/members", params, "GET");
|
||||
// request 拦截器会返回 res.data.data ?? res.data
|
||||
// 对于 { code: 200, data: { list: [...] } } 的返回,拦截器会返回 { list: [...] }
|
||||
const memberList = response?.list || response?.data?.list || [];
|
||||
|
||||
// 映射接口返回的数据结构到我们的接口
|
||||
return memberList.map((item: any) => ({
|
||||
id: String(item.id),
|
||||
nickname: item.nickname || "",
|
||||
wechatId: item.wechatId || "",
|
||||
avatar: item.avatar || "",
|
||||
gender: undefined, // 接口未返回,暂时设为 undefined
|
||||
role: undefined, // 接口未返回,暂时设为 undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("获取群成员失败:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const GroupSelectionWithMembers: React.FC<GroupSelectionWithMembersProps> = ({
|
||||
selectedGroups,
|
||||
onSelect,
|
||||
placeholder = "选择聊天群",
|
||||
className = "",
|
||||
readonly = false,
|
||||
}) => {
|
||||
const [groupSelectionVisible, setGroupSelectionVisible] = useState(false);
|
||||
const [memberSelectionVisible, setMemberSelectionVisible] = useState<{
|
||||
visible: boolean;
|
||||
groupId: string;
|
||||
}>({ visible: false, groupId: "" });
|
||||
const [allMembers, setAllMembers] = useState<Record<string, GroupMember[]>>({});
|
||||
const [selectedMembers, setSelectedMembers] = useState<Record<string, GroupMember[]>>({});
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
const [memberSearchKeyword, setMemberSearchKeyword] = useState("");
|
||||
// 存储完整成员列表(用于搜索时切换回完整列表)
|
||||
const [fullMembersCache, setFullMembersCache] = useState<Record<string, GroupMember[]>>({});
|
||||
|
||||
// 处理群选择
|
||||
const handleGroupSelect = (groups: GroupSelectionItem[]) => {
|
||||
const groupsWithMembers: GroupWithMembers[] = groups.map(group => {
|
||||
const existing = selectedGroups.find(g => g.id === group.id);
|
||||
return {
|
||||
...group,
|
||||
members: existing?.members || [],
|
||||
};
|
||||
});
|
||||
onSelect(groupsWithMembers);
|
||||
setGroupSelectionVisible(false);
|
||||
};
|
||||
|
||||
// 删除群
|
||||
const handleRemoveGroup = (groupId: string) => {
|
||||
if (readonly) return;
|
||||
const newGroups = selectedGroups.filter(g => g.id !== groupId);
|
||||
const newSelectedMembers = { ...selectedMembers };
|
||||
delete newSelectedMembers[groupId];
|
||||
setSelectedMembers(newSelectedMembers);
|
||||
onSelect(newGroups);
|
||||
};
|
||||
|
||||
// 打开成员选择弹窗
|
||||
const handleOpenMemberSelection = async (groupId: string) => {
|
||||
if (readonly) return;
|
||||
setMemberSelectionVisible({ visible: true, groupId });
|
||||
setMemberSearchKeyword(""); // 重置搜索关键词
|
||||
|
||||
// 如果还没有加载过该群的成员列表,则加载所有成员(不使用搜索关键词)
|
||||
if (!allMembers[groupId] && !fullMembersCache[groupId]) {
|
||||
setLoadingMembers(true);
|
||||
try {
|
||||
const members = await getGroupMembers(groupId, 1, 100, "");
|
||||
setAllMembers(prev => ({ ...prev, [groupId]: members }));
|
||||
setFullMembersCache(prev => ({ ...prev, [groupId]: members })); // 缓存完整列表
|
||||
} catch (error) {
|
||||
console.error("加载群成员失败:", error);
|
||||
} finally {
|
||||
setLoadingMembers(false);
|
||||
}
|
||||
} else if (fullMembersCache[groupId] && !allMembers[groupId]) {
|
||||
// 如果有缓存但没有显示列表,恢复完整列表
|
||||
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭成员选择弹窗
|
||||
const handleCloseMemberSelection = () => {
|
||||
setMemberSelectionVisible({ visible: false, groupId: "" });
|
||||
setMemberSearchKeyword(""); // 重置搜索关键词
|
||||
};
|
||||
|
||||
// 手动触发搜索
|
||||
const handleSearchMembers = async () => {
|
||||
const groupId = memberSelectionVisible.groupId;
|
||||
if (!groupId) return;
|
||||
|
||||
const keyword = memberSearchKeyword.trim();
|
||||
|
||||
// 如果搜索关键词为空,使用缓存的完整列表
|
||||
if (!keyword) {
|
||||
if (fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
|
||||
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 有搜索关键词时,调用 API 搜索
|
||||
setLoadingMembers(true);
|
||||
try {
|
||||
const members = await getGroupMembers(groupId, 1, 100, keyword);
|
||||
setAllMembers(prev => ({ ...prev, [groupId]: members }));
|
||||
} catch (error) {
|
||||
console.error("搜索群成员失败:", error);
|
||||
} finally {
|
||||
setLoadingMembers(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 清空搜索
|
||||
const handleClearSearch = () => {
|
||||
setMemberSearchKeyword("");
|
||||
const groupId = memberSelectionVisible.groupId;
|
||||
if (groupId && fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
|
||||
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
|
||||
}
|
||||
};
|
||||
|
||||
// 选择成员
|
||||
const handleSelectMember = (groupId: string, member: GroupMember) => {
|
||||
if (readonly) return;
|
||||
const currentMembers = selectedMembers[groupId] || [];
|
||||
const isSelected = currentMembers.some(m => m.id === member.id);
|
||||
|
||||
let newSelectedMembers = { ...selectedMembers };
|
||||
if (isSelected) {
|
||||
newSelectedMembers[groupId] = currentMembers.filter(m => m.id !== member.id);
|
||||
} else {
|
||||
newSelectedMembers[groupId] = [...currentMembers, member];
|
||||
}
|
||||
setSelectedMembers(newSelectedMembers);
|
||||
|
||||
// 更新群数据
|
||||
const updatedGroups = selectedGroups.map(group => {
|
||||
if (group.id === groupId) {
|
||||
return {
|
||||
...group,
|
||||
members: newSelectedMembers[groupId] || [],
|
||||
};
|
||||
}
|
||||
return group;
|
||||
});
|
||||
onSelect(updatedGroups);
|
||||
};
|
||||
|
||||
// 移除成员
|
||||
const handleRemoveMember = (groupId: string, memberId: string) => {
|
||||
if (readonly) return;
|
||||
const currentMembers = selectedMembers[groupId] || [];
|
||||
const newMembers = currentMembers.filter(m => m.id !== memberId);
|
||||
|
||||
const newSelectedMembers = { ...selectedMembers };
|
||||
newSelectedMembers[groupId] = newMembers;
|
||||
setSelectedMembers(newSelectedMembers);
|
||||
|
||||
// 更新群数据
|
||||
const updatedGroups = selectedGroups.map(group => {
|
||||
if (group.id === groupId) {
|
||||
return {
|
||||
...group,
|
||||
members: newMembers,
|
||||
};
|
||||
}
|
||||
return group;
|
||||
});
|
||||
onSelect(updatedGroups);
|
||||
};
|
||||
|
||||
// 同步 selectedGroups 到 selectedMembers
|
||||
useEffect(() => {
|
||||
const membersMap: Record<string, GroupMember[]> = {};
|
||||
selectedGroups.forEach(group => {
|
||||
if (group.members && group.members.length > 0) {
|
||||
membersMap[group.id] = group.members;
|
||||
}
|
||||
});
|
||||
setSelectedMembers(membersMap);
|
||||
}, [selectedGroups.length]);
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedGroups.length === 0) return "";
|
||||
return `已选择${selectedGroups.length}个群聊`;
|
||||
};
|
||||
|
||||
const currentGroupMembers = allMembers[memberSelectionVisible.groupId] || [];
|
||||
const currentSelectedMembers = selectedMembers[memberSelectionVisible.groupId] || [];
|
||||
|
||||
return (
|
||||
<div className={`${style.container} ${className}`}>
|
||||
{/* 输入框 */}
|
||||
<div
|
||||
className={style.inputWrapper}
|
||||
onClick={() => !readonly && setGroupSelectionVisible(true)}
|
||||
>
|
||||
<SearchOutlined className={style.inputIcon} />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
readOnly
|
||||
className={style.input}
|
||||
/>
|
||||
{!readonly && selectedGroups.length > 0 && (
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
className={style.clearBtn}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setSelectedMembers({});
|
||||
onSelect([]);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已选群列表 */}
|
||||
{selectedGroups.length > 0 && (
|
||||
<div className={style.selectedGroupsList}>
|
||||
{selectedGroups.map(group => (
|
||||
<div key={group.id} className={style.groupCard}>
|
||||
{/* 群信息 */}
|
||||
<div className={style.groupHeader}>
|
||||
<div className={style.groupInfo}>
|
||||
<Avatar src={group.avatar} className={style.groupAvatar} />
|
||||
<div className={style.groupDetails}>
|
||||
<div className={style.groupName}>{group.name}</div>
|
||||
<div className={style.groupId}>ID: {group.chatroomId || group.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
className={style.deleteGroupBtn}
|
||||
onClick={() => handleRemoveGroup(group.id)}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 成员选择区域 */}
|
||||
<div className={style.membersSection}>
|
||||
<div className={style.membersLabel}>
|
||||
采集群内指定成员 ({group.members?.length || 0}人)
|
||||
</div>
|
||||
<div className={style.membersList}>
|
||||
{group.members?.map(member => (
|
||||
<div key={member.id} className={style.memberItem}>
|
||||
<Avatar src={member.avatar} className={style.memberAvatar} />
|
||||
<div className={style.memberName}>{member.nickname}</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
className={style.removeMemberBtn}
|
||||
onClick={() => handleRemoveMember(group.id, member.id)}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readonly && (
|
||||
<div
|
||||
className={style.addMemberBtn}
|
||||
onClick={() => handleOpenMemberSelection(group.id)}
|
||||
>
|
||||
<PlusOutlined />
|
||||
<span>添加</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 群选择弹窗 */}
|
||||
<GroupSelection
|
||||
selectedOptions={selectedGroups as GroupSelectionItem[]}
|
||||
onSelect={handleGroupSelect}
|
||||
placeholder={placeholder}
|
||||
visible={groupSelectionVisible}
|
||||
onVisibleChange={setGroupSelectionVisible}
|
||||
showInput={false}
|
||||
showSelectedList={false}
|
||||
/>
|
||||
|
||||
{/* 成员选择弹窗 */}
|
||||
<Popup
|
||||
visible={memberSelectionVisible.visible}
|
||||
onMaskClick={handleCloseMemberSelection}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
height: "70vh",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
>
|
||||
<div className={style.memberSelectionPopup}>
|
||||
<div className={style.popupHeader}>
|
||||
<div className={style.popupTitle}>选择成员</div>
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
onClick={handleCloseMemberSelection}
|
||||
className={style.closeBtn}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.searchBox}>
|
||||
<div className={style.searchInputWrapper}>
|
||||
<Input
|
||||
placeholder="搜索成员昵称或微信号"
|
||||
value={memberSearchKeyword}
|
||||
onChange={val => setMemberSearchKeyword(val)}
|
||||
onEnterPress={handleSearchMembers}
|
||||
className={style.searchInput}
|
||||
/>
|
||||
{memberSearchKeyword && (
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
className={style.clearSearchBtn}
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleSearchMembers}
|
||||
loading={loadingMembers}
|
||||
className={style.searchBtn}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.memberList}>
|
||||
{loadingMembers ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : currentGroupMembers.length > 0 ? (
|
||||
currentGroupMembers.map(member => {
|
||||
const isSelected = currentSelectedMembers.some(m => m.id === member.id);
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`${style.memberListItem} ${isSelected ? style.selected : ""}`}
|
||||
onClick={() => handleSelectMember(memberSelectionVisible.groupId, member)}
|
||||
>
|
||||
<Avatar src={member.avatar} className={style.memberListItemAvatar} />
|
||||
<div className={style.memberListItemName}>{member.nickname}</div>
|
||||
{isSelected && <div className={style.checkmark}>✓</div>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>暂无成员数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupSelectionWithMembers;
|
||||
@@ -4,7 +4,7 @@ import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getSafeAreaHeight } from "@/utils/common";
|
||||
interface NavCommonProps {
|
||||
title: string;
|
||||
title: string | React.ReactNode;
|
||||
backFn?: () => void;
|
||||
right?: React.ReactNode;
|
||||
left?: React.ReactNode;
|
||||
|
||||
@@ -2,24 +2,37 @@ import request from "@/api/request";
|
||||
|
||||
// 请求参数接口
|
||||
export interface Request {
|
||||
keyword: string;
|
||||
keyword?: string;
|
||||
/**
|
||||
* 条数
|
||||
*/
|
||||
limit: string;
|
||||
limit?: string;
|
||||
pageSize?: string;
|
||||
/**
|
||||
* 分页
|
||||
*/
|
||||
page: string;
|
||||
page?: string;
|
||||
[property: string]: any;
|
||||
}
|
||||
|
||||
// 获取流量池包列表
|
||||
// ===== V2 API =====
|
||||
|
||||
/**
|
||||
* 获取流量池分组列表 (V2)
|
||||
* 用于获取流量池包/分组列表
|
||||
*/
|
||||
export function getPoolPackages(params: Request) {
|
||||
return request("/v1/traffic/pool/getPackage", params, "GET");
|
||||
const v2Params = {
|
||||
page: params.page ? Number(params.page) : 1,
|
||||
pageSize: params.limit ? Number(params.limit) : (params.pageSize ? Number(params.pageSize) : 20),
|
||||
keyword: params.keyword || "",
|
||||
};
|
||||
return request("/v1/traffic/pool/v2/groups", v2Params, "GET");
|
||||
}
|
||||
|
||||
// 保留原接口以兼容现有代码
|
||||
/**
|
||||
* 获取流量池用户列表 (V2)
|
||||
*/
|
||||
export function getPoolList(params: {
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
@@ -27,8 +40,46 @@ export function getPoolList(params: {
|
||||
addStatus?: string;
|
||||
deviceId?: string;
|
||||
packageId?: string;
|
||||
groupId?: string;
|
||||
userValue?: string;
|
||||
[property: string]: any;
|
||||
}) {
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
const v2Params = {
|
||||
page: params.page ? Number(params.page) : 1,
|
||||
pageSize: params.pageSize ? Number(params.pageSize) : 20,
|
||||
keyword: params.keyword || "",
|
||||
groupId: params.groupId || params.packageId || "",
|
||||
};
|
||||
return request("/v1/traffic/pool/v2/group/members", v2Params, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除流量池分组 (V2)
|
||||
*/
|
||||
export function deletePackage(id: number | string) {
|
||||
return request("/v1/traffic/pool/v2/group/delete", { groupId: id }, "DELETE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建流量池分组 (V2)
|
||||
*/
|
||||
export function createPackage(params: {
|
||||
name: string;
|
||||
description?: string;
|
||||
ruleType?: number;
|
||||
ruleConfig?: any;
|
||||
}) {
|
||||
return request("/v1/traffic/pool/v2/group/create", {
|
||||
groupName: params.name,
|
||||
description: params.description || "",
|
||||
ruleType: params.ruleType || 0,
|
||||
ruleConfig: params.ruleConfig || null,
|
||||
}, "POST");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池分组详情 (V2)
|
||||
*/
|
||||
export function getPackageDetail(groupId: number | string) {
|
||||
return request("/v1/traffic/pool/v2/group/detail", { groupId }, "GET");
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function SelectionPopup({
|
||||
PoolSelectionItem[]
|
||||
>([]);
|
||||
|
||||
// 获取流量池包列表API
|
||||
// 获取流量池分组列表API (V2)
|
||||
const fetchPoolPackages = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -52,13 +52,44 @@ export default function SelectionPopup({
|
||||
};
|
||||
|
||||
const response = await getPoolPackages(params);
|
||||
if (response && response.list) {
|
||||
setPoolPackages(response.list);
|
||||
setTotalItems(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
console.log("getPoolPackages response:", response);
|
||||
// request 函数会自动提取 data 字段,所以 response 应该是数组
|
||||
// 但为了兼容,也处理可能的包装格式
|
||||
let groupsList: any[] = [];
|
||||
if (Array.isArray(response)) {
|
||||
groupsList = response;
|
||||
} else if (response?.data && Array.isArray(response.data)) {
|
||||
groupsList = response.data;
|
||||
} else if (response?.list && Array.isArray(response.list)) {
|
||||
groupsList = response.list;
|
||||
}
|
||||
console.log("groupsList:", groupsList);
|
||||
|
||||
if (groupsList.length > 0) {
|
||||
// 适配 V2 API 返回格式
|
||||
const formattedList = groupsList.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.groupName || item.name || `分组${item.id}`,
|
||||
description: item.description || "",
|
||||
createTime: item.createTime
|
||||
? (typeof item.createTime === 'number'
|
||||
? new Date(item.createTime * 1000).toLocaleString('zh-CN')
|
||||
: String(item.createTime).split(' ')[0])
|
||||
: "",
|
||||
num: item.memberCount || item.num || 0,
|
||||
isSystem: item.isSystem,
|
||||
ruleType: item.ruleType,
|
||||
}));
|
||||
setPoolPackages(formattedList);
|
||||
setTotalItems(formattedList.length);
|
||||
setTotalPages(Math.ceil(formattedList.length / 20));
|
||||
} else {
|
||||
setPoolPackages([]);
|
||||
setTotalItems(0);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取流量池包列表失败:", error);
|
||||
console.error("获取流量池分组列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PopupFooterProps {
|
||||
// 全选功能相关
|
||||
isAllSelected?: boolean;
|
||||
onSelectAll?: (checked: boolean) => void;
|
||||
singleSelect?: boolean;
|
||||
}
|
||||
|
||||
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
@@ -26,11 +27,13 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
onConfirm,
|
||||
isAllSelected = false,
|
||||
onSelectAll,
|
||||
singleSelect = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 分页栏 */}
|
||||
<div className={style.paginationRow}>
|
||||
{onSelectAll && (
|
||||
<div className={style.totalCount}>
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
@@ -40,6 +43,7 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
全选当前页
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.paginationControls}>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
@@ -61,7 +65,13 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.popupFooter}>
|
||||
<div className={style.selectedCount}>已选择 {selectedCount} 条记录</div>
|
||||
<div className={style.selectedCount}>
|
||||
{singleSelect
|
||||
? selectedCount > 0
|
||||
? "已选择设备"
|
||||
: "未选择设备"
|
||||
: `已选择 ${selectedCount} 条记录`}
|
||||
</div>
|
||||
<div className={style.footerBtnGroup}>
|
||||
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||
取消
|
||||
|
||||
38
Cunkebao/src/components/StepIndicator/index.module.scss
Normal file
38
Cunkebao/src/components/StepIndicator/index.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
.container {
|
||||
padding: 20px 30px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.steps {
|
||||
--adm-color-primary: #007aff;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-title) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-active .adm-steps-item-title) {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-icon) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-active .adm-steps-item-icon) {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-finish .adm-steps-item-icon) {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Steps } from "antd-mobile";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
@@ -11,28 +12,13 @@ const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||
steps,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step, idx) => (
|
||||
<div className={styles.container}>
|
||||
<Steps current={currentStep - 1} className={styles.steps}>
|
||||
{steps.map((step) => (
|
||||
<Steps.Step
|
||||
key={step.id}
|
||||
title={step.subtitle}
|
||||
icon={
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
}
|
||||
className={styles.step}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
@@ -149,14 +149,14 @@ const Home: React.FC = () => {
|
||||
<div className={style["stat-card"]} onClick={handleDevicesClick}>
|
||||
<div className={style["stat-label"]}>设备数量</div>
|
||||
<div className={style["stat-value"]}>
|
||||
<span>{dashboard.deviceNum || 42}</span>
|
||||
<span>{dashboard.deviceNum || 0}</span>
|
||||
<MobileOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-card"]} onClick={handleWechatClick}>
|
||||
<div className={style["stat-label"]}>微信号数量</div>
|
||||
<div className={style["stat-value"]}>
|
||||
<span>{dashboard.wechatNum || 42}</span>
|
||||
<span>{dashboard.wechatNum || 0}</span>
|
||||
<TeamOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +166,7 @@ const Home: React.FC = () => {
|
||||
>
|
||||
<div className={style["stat-label"]}>在线微信号</div>
|
||||
<div className={style["stat-value"]}>
|
||||
<span>{dashboard.aliveWechatNum || 35}</span>
|
||||
<span>{dashboard.aliveWechatNum || 0}</span>
|
||||
<LineChartOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
||||
</div>
|
||||
<div className={style["progress-bar"]}>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function getContentLibraryDetail(id: string): Promise<any> {
|
||||
export function createContentLibrary(
|
||||
params: CreateContentLibraryParams,
|
||||
): Promise<any> {
|
||||
return request("/v1/content/library/create", params, "POST");
|
||||
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
|
||||
}
|
||||
|
||||
// 更新内容库
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
.form-page {
|
||||
background: #f7f8fa;
|
||||
padding: 16px;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 18px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
padding: 24px 18px 18px 18px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
font-size: 14px;
|
||||
padding: 4px 16px;
|
||||
border-radius: 16px;
|
||||
margin-top: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@@ -32,9 +46,23 @@
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin-top: 28px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
padding-left: 8px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: #1890ff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-block {
|
||||
@@ -47,40 +75,78 @@
|
||||
.adm-tabs-header {
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
.adm-tabs-tab {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 8px 0;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.adm-tabs-tab-active {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.adm-tabs-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse {
|
||||
margin-top: 12px;
|
||||
.keyword-collapse {
|
||||
.adm-collapse-panel-header {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.adm-collapse-panel-content {
|
||||
padding-bottom: 8px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 18px 14px 10px 14px;
|
||||
margin-top: 2px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
padding: 16px 0 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 22px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
display: block;
|
||||
}
|
||||
.adm-input {
|
||||
min-height: 42px;
|
||||
font-size: 15px;
|
||||
border-radius: 7px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.keyword-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.keyword-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.keyword-arrow {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.keyword-collapse .adm-collapse-panel-active .keyword-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ai-row,
|
||||
@@ -91,9 +157,53 @@
|
||||
}
|
||||
|
||||
.ai-desc {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content-type-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.content-type-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.content-type-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-type-btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:not(.active) {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.date-row,
|
||||
@@ -109,6 +219,179 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.time-limit-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.time-limit-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.date-inputs {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.date-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 100%;
|
||||
|
||||
.ant-input {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.enable-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.enable-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.device-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.device-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.online {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
&.offline {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.device-wechat-id {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.device-arrow {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-input-wrapper {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.device-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.keyword-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 32px;
|
||||
height: 48px !important;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Input as AntdInput, Switch } from "antd";
|
||||
import { Input as AntdInput, Switch, Tag } from "antd";
|
||||
import { Button, Collapse, Toast, DatePicker, Tabs } from "antd-mobile";
|
||||
import { DownOutlined } from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import GroupSelectionWithMembers from "@/components/GroupSelectionWithMembers";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
import request from "@/api/request";
|
||||
import { getContentLibraryDetail, updateContentLibrary } from "./api";
|
||||
import { getContentLibraryDetail, updateContentLibrary, createContentLibrary } from "./api";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { GroupWithMembers } from "@/components/GroupSelectionWithMembers";
|
||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
|
||||
const { TextArea } = AntdInput;
|
||||
|
||||
@@ -29,6 +33,15 @@ export default function ContentForm() {
|
||||
const isEdit = !!id;
|
||||
const [sourceType, setSourceType] = useState<"friends" | "groups">("friends");
|
||||
const [name, setName] = useState("");
|
||||
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [deviceSelectionVisible, setDeviceSelectionVisible] = useState(false);
|
||||
|
||||
// 处理设备选择(单选模式)
|
||||
const handleDeviceSelect = (devices: DeviceSelectionItem[]) => {
|
||||
// 单选模式:只保留最后一个选中的设备
|
||||
setSelectedDevices(devices.length > 0 ? [devices[devices.length - 1]] : []);
|
||||
setDeviceSelectionVisible(false);
|
||||
};
|
||||
const [friendsGroups, setSelectedFriends] = useState<string[]>([]);
|
||||
const [friendsGroupsOptions, setSelectedFriendsOptions] = useState<
|
||||
FriendSelectionItem[]
|
||||
@@ -37,6 +50,9 @@ export default function ContentForm() {
|
||||
const [selectedGroupsOptions, setSelectedGroupsOptions] = useState<
|
||||
GroupSelectionItem[]
|
||||
>([]);
|
||||
const [selectedGroupsWithMembers, setSelectedGroupsWithMembers] = useState<
|
||||
GroupWithMembers[]
|
||||
>([]);
|
||||
const [useAI, setUseAI] = useState(false);
|
||||
const [aiPrompt, setAIPrompt] = useState("重写这条朋友圈 要求: 1、原本的字数和意思不要修改超过10% 2、出现品牌名或个人名字就去除");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
@@ -52,6 +68,7 @@ export default function ContentForm() {
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"link",
|
||||
]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -64,15 +81,66 @@ export default function ContentForm() {
|
||||
.then(data => {
|
||||
setName(data.name || "");
|
||||
setSourceType(data.sourceType === 1 ? "friends" : "groups");
|
||||
// 详情接口中的采集设备数据可能在 devices / selectedDevices / deviceGroupsOptions 中
|
||||
const deviceOptions: DeviceSelectionItem[] =
|
||||
data.devices ||
|
||||
data.selectedDevices ||
|
||||
(data.deviceGroupsOptions
|
||||
? (data.deviceGroupsOptions as any[]).map(item => ({
|
||||
id: item.id,
|
||||
memo: item.memo,
|
||||
imei: item.imei,
|
||||
wechatId: item.wechatId,
|
||||
status: item.alive === 1 ? "online" : "offline",
|
||||
nickname: item.nickname,
|
||||
avatar: item.avatar,
|
||||
totalFriend: item.totalFriend,
|
||||
}))
|
||||
: []);
|
||||
setSelectedDevices(deviceOptions || []);
|
||||
setSelectedFriends(data.sourceFriends || []);
|
||||
setSelectedGroups(data.selectedGroups || []);
|
||||
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
|
||||
// 使用 wechatGroupsOptions 作为群列表数据
|
||||
const groupsOptions = data.wechatGroupsOptions || data.selectedGroupsOptions || [];
|
||||
setSelectedGroupsOptions(groupsOptions);
|
||||
// 从 groupsOptions 中提取群 ID 列表,如果没有 selectedGroups 的话
|
||||
const groupIds = data.selectedGroups && data.selectedGroups.length > 0
|
||||
? data.selectedGroups
|
||||
: groupsOptions.map((g: any) => String(g.id));
|
||||
setSelectedGroups(groupIds);
|
||||
// 处理带成员的群数据
|
||||
// groupMembersOptions 是一个对象,key是群ID(字符串),value是成员数组
|
||||
const groupMembersMap = data.groupMembersOptions || {};
|
||||
const groupsWithMembers: GroupWithMembers[] = groupsOptions.map(
|
||||
(group: any) => {
|
||||
const groupIdStr = String(group.id);
|
||||
const members = groupMembersMap[groupIdStr] || [];
|
||||
// 映射成员数据结构
|
||||
return {
|
||||
...group,
|
||||
members: members.map((member: any) => ({
|
||||
id: String(member.id),
|
||||
nickname: member.nickname || "",
|
||||
wechatId: member.wechatId || "",
|
||||
avatar: member.avatar || "",
|
||||
gender: undefined,
|
||||
role: undefined,
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
setSelectedGroupsWithMembers(groupsWithMembers);
|
||||
setSelectedFriendsOptions(data.friendsGroupsOptions || []);
|
||||
setKeywordsInclude((data.keywordInclude || []).join(","));
|
||||
setKeywordsExclude((data.keywordExclude || []).join(","));
|
||||
setCatchType(data.catchType || ["text", "image", "video"]);
|
||||
setCatchType(data.catchType || ["text", "image", "video", "link"]);
|
||||
setAIPrompt(data.aiPrompt || "");
|
||||
// aiEnabled 为 AI 提示词开关,1 开启 0 关闭
|
||||
if (typeof data.aiEnabled !== "undefined") {
|
||||
setUseAI(data.aiEnabled === 1);
|
||||
} else {
|
||||
// 兼容旧数据,默认根据是否有 aiPrompt 判断
|
||||
setUseAI(!!data.aiPrompt);
|
||||
}
|
||||
setEnabled(data.status === 1);
|
||||
// 时间范围
|
||||
const start = data.timeStart || data.startTime;
|
||||
@@ -103,9 +171,18 @@ export default function ContentForm() {
|
||||
const payload = {
|
||||
name,
|
||||
sourceType: sourceType === "friends" ? 1 : 2,
|
||||
devices: selectedDevices.map(d => d.id),
|
||||
friendsGroups: friendsGroups,
|
||||
wechatGroups: selectedGroups,
|
||||
groupMembers: {},
|
||||
groupMembers: selectedGroupsWithMembers.reduce(
|
||||
(acc, group) => {
|
||||
if (group.members && group.members.length > 0) {
|
||||
acc[group.id] = group.members.map(m => m.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
),
|
||||
keywordInclude: keywordsInclude
|
||||
.split(/,|,|\n|\s+/)
|
||||
.map(s => s.trim())
|
||||
@@ -116,6 +193,7 @@ export default function ContentForm() {
|
||||
.filter(Boolean),
|
||||
catchType,
|
||||
aiPrompt,
|
||||
aiEnabled: useAI ? 1 : 0,
|
||||
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
|
||||
startTime: dateRange[0] ? formatDate(dateRange[0]) : "",
|
||||
endTime: dateRange[1] ? formatDate(dateRange[1]) : "",
|
||||
@@ -125,7 +203,7 @@ export default function ContentForm() {
|
||||
await updateContentLibrary({ id, ...payload });
|
||||
Toast.show({ content: "保存成功", position: "top" });
|
||||
} else {
|
||||
await request("/v1/content/library/create", payload, "POST");
|
||||
await createContentLibrary(payload);
|
||||
Toast.show({ content: "创建成功", position: "top" });
|
||||
}
|
||||
navigate("/mine/content");
|
||||
@@ -144,6 +222,12 @@ export default function ContentForm() {
|
||||
setSelectedGroupsOptions(groups);
|
||||
};
|
||||
|
||||
const handleGroupsWithMembersChange = (groups: GroupWithMembers[]) => {
|
||||
setSelectedGroupsWithMembers(groups);
|
||||
setSelectedGroups(groups.map(g => g.id.toString()));
|
||||
setSelectedGroupsOptions(groups);
|
||||
};
|
||||
|
||||
const handleFriendsChange = (friends: FriendSelectionItem[]) => {
|
||||
setSelectedFriends(friends.map(f => f.id.toString()));
|
||||
setSelectedFriendsOptions(friends);
|
||||
@@ -178,7 +262,7 @@ export default function ContentForm() {
|
||||
onSubmit={e => e.preventDefault()}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className={style["form-section"]}>
|
||||
<div className={style["form-card"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
|
||||
内容库名称
|
||||
@@ -191,8 +275,100 @@ export default function ContentForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["section-title"]}>数据来源配置</div>
|
||||
<div className={style["form-section"]}>
|
||||
<div className={style["form-card"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
|
||||
来源渠道
|
||||
</label>
|
||||
<Tag color="blue" className={style["source-tag"]}>
|
||||
微信
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div className={style["form-card"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
|
||||
选择采集设备
|
||||
</label>
|
||||
{selectedDevices.length > 0 ? (
|
||||
<div
|
||||
className={style["device-card"]}
|
||||
onClick={() => setDeviceSelectionVisible(true)}
|
||||
>
|
||||
<div className={style["device-avatar"]}>
|
||||
{selectedDevices[0].avatar ? (
|
||||
<img
|
||||
src={selectedDevices[0].avatar}
|
||||
alt="头像"
|
||||
className={style["avatar-img"]}
|
||||
/>
|
||||
) : (
|
||||
<span className={style["avatar-text"]}>
|
||||
{(selectedDevices[0].memo ||
|
||||
selectedDevices[0].wechatId ||
|
||||
"设")[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={style["device-info"]}>
|
||||
<div className={style["device-name-row"]}>
|
||||
<span className={style["device-name"]}>
|
||||
{selectedDevices[0].nickname || selectedDevices[0].memo}
|
||||
</span>
|
||||
{selectedDevices[0].memo &&
|
||||
selectedDevices[0].nickname &&
|
||||
selectedDevices[0].memo !== selectedDevices[0].nickname && (
|
||||
<span className={style["device-tag"]}>
|
||||
{selectedDevices[0].memo}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`${style["status-dot"]} ${
|
||||
selectedDevices[0].status === "online"
|
||||
? style["online"]
|
||||
: style["offline"]
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["device-wechat-id"]}>
|
||||
微信ID: {selectedDevices[0].wechatId}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["device-arrow"]}>
|
||||
<DownOutlined />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style["device-input-wrapper"]}>
|
||||
<DeviceSelection
|
||||
selectedOptions={selectedDevices}
|
||||
onSelect={handleDeviceSelect}
|
||||
placeholder="选择采集设备"
|
||||
showInput={true}
|
||||
showSelectedList={false}
|
||||
singleSelect={true}
|
||||
className={style["device-input"]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 隐藏的设备选择组件,用于打开弹窗 */}
|
||||
<div style={{ display: "none" }}>
|
||||
<DeviceSelection
|
||||
selectedOptions={selectedDevices}
|
||||
onSelect={handleDeviceSelect}
|
||||
placeholder="选择采集设备"
|
||||
showInput={false}
|
||||
showSelectedList={false}
|
||||
singleSelect={true}
|
||||
mode="dialog"
|
||||
open={deviceSelectionVisible}
|
||||
onOpenChange={setDeviceSelectionVisible}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["section-title"]}>采集内容配置</div>
|
||||
<div className={style["form-card"]}>
|
||||
<Tabs
|
||||
activeKey={sourceType}
|
||||
onChange={key => setSourceType(key as "friends" | "groups")}
|
||||
@@ -203,22 +379,29 @@ export default function ContentForm() {
|
||||
selectedOptions={friendsGroupsOptions}
|
||||
onSelect={handleFriendsChange}
|
||||
placeholder="选择微信好友"
|
||||
deviceIds={selectedDevices.map(d => Number(d.id))}
|
||||
/>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab title="选择聊天群" key="groups">
|
||||
<GroupSelection
|
||||
selectedOptions={selectedGroupsOptions}
|
||||
onSelect={handleGroupsChange}
|
||||
<GroupSelectionWithMembers
|
||||
selectedGroups={selectedGroupsWithMembers}
|
||||
onSelect={handleGroupsWithMembersChange}
|
||||
placeholder="选择聊天群"
|
||||
/>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Collapse className={style["collapse"]}>
|
||||
<div className={style["form-card"]}>
|
||||
<Collapse className={style["keyword-collapse"]}>
|
||||
<Collapse.Panel
|
||||
key="keywords"
|
||||
title={<span className={style["form-label"]}>关键词设置</span>}
|
||||
title={
|
||||
<div className={style["keyword-header"]}>
|
||||
<span className={style["keyword-title"]}>关键词设置</span>
|
||||
<DownOutlined className={style["keyword-arrow"]} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style["form-section"]}>
|
||||
<label className={style["form-label"]}>包含关键词</label>
|
||||
@@ -242,27 +425,20 @@ export default function ContentForm() {
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
{/* 采集内容类型 */}
|
||||
<div className={style["section-title"]}>采集内容类型</div>
|
||||
<div className={style["form-section"]}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
{["text", "image", "video"].map(type => (
|
||||
<div
|
||||
<div className={style["form-card"]}>
|
||||
<div className={style["content-type-header"]}>
|
||||
<span className={style["content-type-title"]}>采集内容类型</span>
|
||||
</div>
|
||||
<div className={style["content-type-buttons"]}>
|
||||
{["text", "image", "video", "link"].map(type => (
|
||||
<button
|
||||
key={type}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: catchType.includes(type)
|
||||
? "#1890ff"
|
||||
: "#fff",
|
||||
color: catchType.includes(type) ? "#fff" : "#333",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={`${style["content-type-btn"]} ${
|
||||
catchType.includes(type) ? style["active"] : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCatchType(prev =>
|
||||
prev.includes(type)
|
||||
@@ -271,26 +447,26 @@ export default function ContentForm() {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{type === "text"
|
||||
? "文本"
|
||||
: type === "image"
|
||||
? "图片"
|
||||
: "视频"}
|
||||
</span>
|
||||
</div>
|
||||
: type === "video"
|
||||
? "视频"
|
||||
: "链接"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["section-title"]}>是否启用AI</div>
|
||||
<div className={style["form-card"]}>
|
||||
<div
|
||||
className={style["form-section"]}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Switch checked={useAI} onChange={setUseAI} />
|
||||
<span className={style["ai-desc"]}>
|
||||
启用AI后,该内容库下的所有内容都会通过AI生成
|
||||
启用后,该内容库下的内容会通过AI生成
|
||||
</span>
|
||||
</div>
|
||||
{useAI && (
|
||||
@@ -305,19 +481,24 @@ export default function ContentForm() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style["section-title"]}>时间限制</div>
|
||||
<div
|
||||
className={style["form-section"]}
|
||||
style={{ display: "flex", gap: 12 }}
|
||||
>
|
||||
<label>开始时间</label>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={style["form-card"]}>
|
||||
<div className={style["time-limit-header"]}>
|
||||
<span className={style["time-limit-title"]}>时间限制</span>
|
||||
</div>
|
||||
<div className={style["date-inputs"]}>
|
||||
<div className={style["date-item"]}>
|
||||
<label className={style["date-label"]}>开始时间</label>
|
||||
<AntdInput
|
||||
readOnly
|
||||
value={dateRange[0] ? dateRange[0].toLocaleDateString() : ""}
|
||||
value={
|
||||
dateRange[0]
|
||||
? `${dateRange[0].getFullYear()}/${String(dateRange[0].getMonth() + 1).padStart(2, "0")}/${String(dateRange[0].getDate()).padStart(2, "0")}`
|
||||
: ""
|
||||
}
|
||||
placeholder="年/月/日"
|
||||
className={style["input"]}
|
||||
className={style["date-input"]}
|
||||
onClick={() => setShowStartPicker(true)}
|
||||
/>
|
||||
<DatePicker
|
||||
@@ -331,13 +512,17 @@ export default function ContentForm() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label>结束时间</label>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={style["date-item"]}>
|
||||
<label className={style["date-label"]}>结束时间</label>
|
||||
<AntdInput
|
||||
readOnly
|
||||
value={dateRange[1] ? dateRange[1].toLocaleDateString() : ""}
|
||||
value={
|
||||
dateRange[1]
|
||||
? `${dateRange[1].getFullYear()}/${String(dateRange[1].getMonth() + 1).padStart(2, "0")}/${String(dateRange[1].getDate()).padStart(2, "0")}`
|
||||
: ""
|
||||
}
|
||||
placeholder="年/月/日"
|
||||
className={style["input"]}
|
||||
className={style["date-input"]}
|
||||
onClick={() => setShowEndPicker(true)}
|
||||
/>
|
||||
<DatePicker
|
||||
@@ -350,21 +535,15 @@ export default function ContentForm() {
|
||||
setShowEndPicker(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={style["section-title"]}
|
||||
style={{
|
||||
marginTop: 24,
|
||||
marginBottom: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>是否启用</span>
|
||||
<div className={style["form-card"]}>
|
||||
<div className={style["enable-section"]}>
|
||||
<span className={style["enable-label"]}>是否启用</span>
|
||||
<Switch checked={enabled} onChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function getContentLibraryList(params: {
|
||||
keyword?: string;
|
||||
sourceType?: number;
|
||||
}): Promise<any> {
|
||||
return request("/v1/content/library/list", params, "GET");
|
||||
return request("/v1/content/library/list", { ...params, formType: 0 }, "GET");
|
||||
}
|
||||
|
||||
// 获取内容库详情
|
||||
@@ -24,7 +24,7 @@ export function getContentLibraryDetail(id: string): Promise<any> {
|
||||
export function createContentLibrary(
|
||||
params: CreateContentLibraryParams,
|
||||
): Promise<any> {
|
||||
return request("/v1/content/library/create", params, "POST");
|
||||
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
|
||||
}
|
||||
|
||||
// 更新内容库
|
||||
|
||||
@@ -47,3 +47,11 @@ export function aiRewriteContent(params: AIRewriteParams) {
|
||||
export function replaceContent(params: ReplaceContentParams) {
|
||||
return request("/v1/content/library/aiEditContent", params, "POST");
|
||||
}
|
||||
|
||||
// 导入Excel素材
|
||||
export function importMaterialsFromExcel(params: {
|
||||
id: string;
|
||||
fileUrl: string;
|
||||
}) {
|
||||
return request("/v1/content/library/import-excel", params, "POST");
|
||||
}
|
||||
|
||||
@@ -776,3 +776,87 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导入弹窗样式
|
||||
.import-popup-content {
|
||||
padding: 20px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
|
||||
.import-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e8f4ff;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #1677ff;
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-form {
|
||||
.import-form-item {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.import-form-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #1677ff;
|
||||
margin-right: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.import-form-control {
|
||||
.import-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-actions {
|
||||
margin-top: 24px;
|
||||
|
||||
button {
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:first-child {
|
||||
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
AppstoreOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { getContentItemList, deleteContentItem, aiRewriteContent, replaceContent } from "./api";
|
||||
import { getContentItemList, deleteContentItem, aiRewriteContent, replaceContent, importMaterialsFromExcel } from "./api";
|
||||
import FileUpload from "@/components/Upload/FileUpload";
|
||||
import { ContentItem } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
@@ -50,6 +52,11 @@ const MaterialsList: React.FC = () => {
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [replaceLoading, setReplaceLoading] = useState(false);
|
||||
|
||||
// 导入相关状态
|
||||
const [showImportPopup, setShowImportPopup] = useState(false);
|
||||
const [importFileUrl, setImportFileUrl] = useState<string>("");
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
// 获取素材列表
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
if (!id) return;
|
||||
@@ -187,6 +194,64 @@ const MaterialsList: React.FC = () => {
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
// 处理导入文件上传
|
||||
const handleImportFileChange = (fileInfo: { fileName: string; fileUrl: string }) => {
|
||||
setImportFileUrl(fileInfo.fileUrl);
|
||||
};
|
||||
|
||||
// 执行导入
|
||||
const handleImport = async () => {
|
||||
if (!id) {
|
||||
Toast.show({
|
||||
content: "内容库ID不存在",
|
||||
position: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!importFileUrl) {
|
||||
Toast.show({
|
||||
content: "请先上传Excel文件",
|
||||
position: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setImportLoading(true);
|
||||
await importMaterialsFromExcel({
|
||||
id: id,
|
||||
fileUrl: importFileUrl,
|
||||
});
|
||||
|
||||
Toast.show({
|
||||
content: "导入成功",
|
||||
position: "top",
|
||||
});
|
||||
|
||||
// 关闭弹窗并重置状态
|
||||
setShowImportPopup(false);
|
||||
setImportFileUrl("");
|
||||
|
||||
// 刷新素材列表
|
||||
fetchMaterials();
|
||||
} catch (error: unknown) {
|
||||
console.error("导入失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? error.message : "导入失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭导入弹窗
|
||||
const closeImportPopup = () => {
|
||||
setShowImportPopup(false);
|
||||
setImportFileUrl("");
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
@@ -354,9 +419,18 @@ const MaterialsList: React.FC = () => {
|
||||
title="素材管理"
|
||||
backFn={() => navigate("/mine/content")}
|
||||
right={
|
||||
<Button type="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建素材
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setShowImportPopup(true)}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
<UploadOutlined /> 导入
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建素材
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* 搜索栏 */}
|
||||
@@ -586,6 +660,71 @@ const MaterialsList: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
{/* 导入弹窗 */}
|
||||
<Popup
|
||||
visible={showImportPopup}
|
||||
onMaskClick={closeImportPopup}
|
||||
bodyStyle={{
|
||||
borderRadius: "16px 16px 0 0",
|
||||
maxHeight: "90vh",
|
||||
}}
|
||||
>
|
||||
<div className={style["import-popup-content"]}>
|
||||
<div className={style["import-popup-header"]}>
|
||||
<h3>导入素材</h3>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={closeImportPopup}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={style["import-form"]}>
|
||||
<div className={style["import-form-item"]}>
|
||||
<div className={style["import-form-label"]}>选择Excel文件</div>
|
||||
<div className={style["import-form-control"]}>
|
||||
<FileUpload
|
||||
value={importFileUrl}
|
||||
onChange={(url) => {
|
||||
const fileUrl = Array.isArray(url) ? url[0] : url;
|
||||
setImportFileUrl(fileUrl || "");
|
||||
}}
|
||||
acceptTypes={["excel"]}
|
||||
maxSize={50}
|
||||
maxCount={1}
|
||||
showPreview={false}
|
||||
/>
|
||||
<div className={style["import-tip"]}>
|
||||
请上传Excel格式的文件,文件大小不超过50MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["import-actions"]}>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={handleImport}
|
||||
loading={importLoading}
|
||||
disabled={importLoading || !importFileUrl}
|
||||
>
|
||||
{importLoading ? "导入中..." : "确认导入"}
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
color="danger"
|
||||
fill="outline"
|
||||
onClick={closeImportPopup}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,3 +42,7 @@ export const fetchDeviceQRCode = (accountId: string) =>
|
||||
// 通过IMEI添加设备
|
||||
export const addDeviceByImei = (imei: string, name: string) =>
|
||||
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
|
||||
|
||||
// 获取设备添加结果(用于轮询检查)
|
||||
export const fetchAddResults = (params: { accountId?: string }) =>
|
||||
request("/v1/devices/add-results", params, "GET");
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fetchDeviceQRCode,
|
||||
addDeviceByImei,
|
||||
deleteDevice,
|
||||
fetchAddResults,
|
||||
} from "./api";
|
||||
import type { Device } from "@/types/device";
|
||||
import { comfirm } from "@/utils/common";
|
||||
@@ -44,12 +45,18 @@ const Devices: React.FC = () => {
|
||||
const [name, setName] = useState("");
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
|
||||
// 轮询监听相关
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const loadDevicesRef = useRef<((reset?: boolean) => Promise<void>) | null>(null);
|
||||
|
||||
// 删除弹窗
|
||||
const [delVisible, setDelVisible] = useState(false);
|
||||
const [delLoading, setDelLoading] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserStore();
|
||||
|
||||
// 加载设备列表
|
||||
const loadDevices = useCallback(
|
||||
async (reset = false) => {
|
||||
@@ -74,6 +81,11 @@ const Devices: React.FC = () => {
|
||||
[loading, search, page],
|
||||
);
|
||||
|
||||
// 更新 loadDevices 的 ref
|
||||
useEffect(() => {
|
||||
loadDevicesRef.current = loadDevices;
|
||||
}, [loadDevices]);
|
||||
|
||||
// 首次加载和搜索
|
||||
useEffect(() => {
|
||||
loadDevices(true);
|
||||
@@ -110,6 +122,56 @@ const Devices: React.FC = () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
// 开始轮询监听设备状态
|
||||
const startPolling = useCallback(() => {
|
||||
if (isPolling) return;
|
||||
|
||||
setIsPolling(true);
|
||||
|
||||
const pollDeviceStatus = async () => {
|
||||
try {
|
||||
const res = await fetchAddResults({ accountId: user?.s2_accountId });
|
||||
if (res.added) {
|
||||
Toast.show({ content: "设备添加成功!", position: "top" });
|
||||
setAddVisible(false);
|
||||
setIsPolling(false);
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
// 刷新设备列表
|
||||
if (loadDevicesRef.current) {
|
||||
await loadDevicesRef.current(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("轮询检查设备状态失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 每3秒检查一次设备状态
|
||||
pollingRef.current = setInterval(pollDeviceStatus, 3000);
|
||||
}, [isPolling, user?.s2_accountId]);
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = useCallback(() => {
|
||||
setIsPolling(false);
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取二维码
|
||||
const handleGetQr = async () => {
|
||||
setQrLoading(true);
|
||||
@@ -119,6 +181,8 @@ const Devices: React.FC = () => {
|
||||
if (!accountId) throw new Error("未获取到用户信息");
|
||||
const res = await fetchDeviceQRCode(accountId);
|
||||
setQrCode(res.qrCode);
|
||||
// 获取二维码后开始轮询监听
|
||||
startPolling();
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
|
||||
} finally {
|
||||
@@ -362,7 +426,11 @@ const Devices: React.FC = () => {
|
||||
{/* 添加设备弹窗 */}
|
||||
<Popup
|
||||
visible={addVisible}
|
||||
onMaskClick={() => setAddVisible(false)}
|
||||
onMaskClick={() => {
|
||||
setAddVisible(false);
|
||||
stopPolling();
|
||||
setQrCode(null);
|
||||
}}
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
@@ -403,6 +471,13 @@ const Devices: React.FC = () => {
|
||||
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
|
||||
请用手机扫码添加设备
|
||||
</div>
|
||||
{isPolling && (
|
||||
<div
|
||||
style={{ color: "#1890ff", fontSize: 12, marginTop: 8 }}
|
||||
>
|
||||
正在监听设备添加状态...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import request from "@/api/request";
|
||||
export interface PowerPackage {
|
||||
id: number;
|
||||
name: string;
|
||||
tokens: number; // 算力点数
|
||||
tokens: number | string; // 算力点数(可能是字符串,如"2,800")
|
||||
price: number; // 价格(分)
|
||||
originalPrice: number; // 原价(分)
|
||||
unitPrice: number; // 单价
|
||||
@@ -13,7 +13,7 @@ export interface PowerPackage {
|
||||
isRecommend: number; // 是否推荐
|
||||
isHot: number; // 是否热门
|
||||
isVip: number; // 是否VIP
|
||||
features: string[]; // 功能特性
|
||||
features?: string[]; // 功能特性(可选)
|
||||
description: string[]; // 描述关键词
|
||||
status: number;
|
||||
createTime: string;
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface Statistics {
|
||||
monthUsed: number; // 本月使用
|
||||
remainingTokens: number; // 剩余算力
|
||||
totalConsumed: number; // 总消耗
|
||||
yesterdayUsed?: number; // 昨日消耗
|
||||
historyConsumed?: number; // 历史消耗
|
||||
estimatedDays?: number; // 预计可用天数
|
||||
}
|
||||
// 算力统计接口
|
||||
export function getStatistics(): Promise<Statistics> {
|
||||
@@ -143,3 +146,56 @@ export function buyPackage(params: { id: number; price: number }) {
|
||||
export function buyCustomPower(params: { amount: number }) {
|
||||
return request("/v1/power/buy-custom", params, "POST");
|
||||
}
|
||||
|
||||
// 查询订单状态
|
||||
export interface QueryOrderResponse {
|
||||
id: number;
|
||||
mchId: number;
|
||||
companyId: number;
|
||||
userId: number;
|
||||
orderType: number;
|
||||
status: number; // 0: 待支付, 1: 已支付
|
||||
goodsId: number;
|
||||
goodsName: string;
|
||||
goodsSpecs: string;
|
||||
money: number;
|
||||
orderNo: string;
|
||||
payType: number | null;
|
||||
payTime: number | null;
|
||||
payInfo: any;
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
export function queryOrder(orderNo: string): Promise<QueryOrderResponse> {
|
||||
return request("/v1/tokens/queryOrder", { orderNo }, "GET");
|
||||
}
|
||||
|
||||
// 账号信息
|
||||
export interface Account {
|
||||
id: number;
|
||||
uid?: number; // 用户ID(用于分配算力)
|
||||
userId?: number; // 用户ID(别名)
|
||||
userName: string;
|
||||
realName: string;
|
||||
nickname: string;
|
||||
departmentId: number;
|
||||
departmentName: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
// 获取账号列表
|
||||
export function getAccountList(): Promise<{ list: Account[]; total: number }> {
|
||||
return request("/v1/kefu/accounts/list", undefined, "GET");
|
||||
}
|
||||
|
||||
// 分配算力接口参数
|
||||
export interface AllocateTokensParams {
|
||||
targetUserId: number; // 目标用户ID
|
||||
tokens: number; // 分配的算力数量
|
||||
remarks?: string; // 备注
|
||||
}
|
||||
|
||||
// 分配算力
|
||||
export function allocateTokens(params: AllocateTokensParams): Promise<any> {
|
||||
return request("/v1/tokens/allocate", params, "POST");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ export interface TokensUseRecordItem {
|
||||
|
||||
export interface TokensUseRecordList {
|
||||
list: TokensUseRecordItem[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
//算力使用明细
|
||||
|
||||
590
Cunkebao/src/pages/mobile/mine/traffic-pool/api.ts
Normal file
590
Cunkebao/src/pages/mobile/mine/traffic-pool/api.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
// RFM 评分
|
||||
export interface RfmScore {
|
||||
R: number;
|
||||
F: number;
|
||||
M: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 分组规则条件
|
||||
export interface RuleCondition {
|
||||
type: "field" | "group" | "tag";
|
||||
field?: string;
|
||||
operator?: string;
|
||||
value?: any;
|
||||
valueType?: string;
|
||||
logic?: "AND" | "OR";
|
||||
conditions?: RuleCondition[];
|
||||
}
|
||||
|
||||
// 分组规则配置
|
||||
export interface RuleConfig {
|
||||
logic: "AND" | "OR";
|
||||
conditions: RuleCondition[];
|
||||
}
|
||||
|
||||
// 流量池分组
|
||||
export interface TrafficPoolGroup {
|
||||
id: number;
|
||||
companyId: number;
|
||||
groupCode: string;
|
||||
groupName: string;
|
||||
groupIcon: string | null;
|
||||
groupColor: string | null;
|
||||
description: string | null;
|
||||
isSystem: number;
|
||||
isDefault: number;
|
||||
ruleType: number; // 1=动态规则,2=手动添加
|
||||
ruleConfig: RuleConfig | null;
|
||||
memberCount: number;
|
||||
sort: number;
|
||||
status: number;
|
||||
createTime: number;
|
||||
// RFM 统计
|
||||
avgRfmR?: number;
|
||||
avgRfmF?: number;
|
||||
avgRfmM?: number;
|
||||
avgRfmScore?: number;
|
||||
}
|
||||
|
||||
// 流量池成员
|
||||
export interface TrafficPoolMember {
|
||||
id: number;
|
||||
poolId: number;
|
||||
identifier: string;
|
||||
companyId: number;
|
||||
friendStatus: number;
|
||||
level: number;
|
||||
intentionLevel: number;
|
||||
lifecycle: number;
|
||||
lastInteractTime: number | null;
|
||||
rfmR: number;
|
||||
rfmF: number;
|
||||
rfmM: number;
|
||||
rfmScore: RfmScore;
|
||||
totalMsgCount: number;
|
||||
totalOrderCount: number;
|
||||
totalOrderAmount: number;
|
||||
ownerWechatId: string | null;
|
||||
allocateStatus: number;
|
||||
realName: string | null;
|
||||
phone: string | null;
|
||||
createTime: number;
|
||||
// 关联的总表信息
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
gender: number;
|
||||
wechatId: string | null;
|
||||
wechatAlias: string | null;
|
||||
mobile: string | null;
|
||||
region: string | null;
|
||||
// 标签
|
||||
tags: TrafficPoolTag[];
|
||||
}
|
||||
|
||||
// 流量池标签
|
||||
export interface TrafficPoolTag {
|
||||
id: number;
|
||||
tagDefineId: number;
|
||||
tagName: string;
|
||||
tagType: number;
|
||||
tagValue: string | null;
|
||||
}
|
||||
|
||||
// 标签类目
|
||||
export interface TagCategory {
|
||||
id: number;
|
||||
companyId: number;
|
||||
parentId: number;
|
||||
tagType: number;
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
categoryColor: string | null;
|
||||
description: string | null;
|
||||
isSystem: number;
|
||||
children?: TagCategory[];
|
||||
}
|
||||
|
||||
// 标签定义
|
||||
export interface TagDefine {
|
||||
id: number;
|
||||
companyId: number;
|
||||
categoryId: number;
|
||||
tagType: number;
|
||||
tagCode: string;
|
||||
tagName: string;
|
||||
tagIcon: string | null;
|
||||
tagColor: string | null;
|
||||
description: string | null;
|
||||
isSystem: number;
|
||||
isExclusive: number;
|
||||
useCount: number;
|
||||
}
|
||||
|
||||
// 流量来源
|
||||
export interface TrafficSource {
|
||||
id: number;
|
||||
poolCompanyId: number;
|
||||
sourceType: number;
|
||||
sourceName: string | null;
|
||||
isFirstSource: number;
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
// 流量行为
|
||||
export interface TrafficBehavior {
|
||||
id: number;
|
||||
poolCompanyId: number;
|
||||
behaviorType: number;
|
||||
behaviorName: string | null;
|
||||
targetName: string | null;
|
||||
amount: number;
|
||||
behaviorTime: number;
|
||||
}
|
||||
|
||||
// 分配记录
|
||||
export interface AllotRecord {
|
||||
id: number;
|
||||
poolCompanyId: number;
|
||||
allotType: number;
|
||||
fromWechatId: string | null;
|
||||
toWechatId: string;
|
||||
expireDays: number;
|
||||
status: number;
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
export interface TrafficStatistics {
|
||||
totalCount: number;
|
||||
friendCount: number;
|
||||
todayNewCount: number;
|
||||
weekNewCount: number;
|
||||
monthNewCount: number;
|
||||
levelDistribution: { level: number; count: number }[];
|
||||
lifecycleDistribution: { lifecycle: number; count: number }[];
|
||||
sourceDistribution: { sourceType: number; count: number }[];
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
export interface PageResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// ==================== 分组相关 API ====================
|
||||
|
||||
/**
|
||||
* 获取流量池分组列表
|
||||
*/
|
||||
export async function getGroups(): Promise<TrafficPoolGroup[]> {
|
||||
return request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组详情
|
||||
*/
|
||||
export async function getGroupDetail(
|
||||
groupId: number,
|
||||
): Promise<TrafficPoolGroup> {
|
||||
return request("/v1/traffic/pool/v2/group/detail", { groupId }, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分组
|
||||
*/
|
||||
export async function createGroup(data: {
|
||||
groupName: string;
|
||||
groupCode?: string;
|
||||
groupIcon?: string;
|
||||
groupColor?: string;
|
||||
description?: string;
|
||||
ruleType?: number;
|
||||
ruleConfig?: RuleConfig;
|
||||
sort?: number;
|
||||
}): Promise<{ id: number }> {
|
||||
return request("/v1/traffic/pool/v2/group/create", data, "POST");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分组
|
||||
*/
|
||||
export async function updateGroup(
|
||||
groupId: number,
|
||||
data: {
|
||||
groupName?: string;
|
||||
groupIcon?: string;
|
||||
groupColor?: string;
|
||||
description?: string;
|
||||
ruleType?: number;
|
||||
ruleConfig?: RuleConfig;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/group/update",
|
||||
{ groupId, ...data },
|
||||
"PUT",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分组
|
||||
*/
|
||||
export async function deleteGroup(groupId: number): Promise<void> {
|
||||
return request("/v1/traffic/pool/v2/group/delete", { groupId }, "DELETE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组成员列表
|
||||
*/
|
||||
export async function getGroupMembers(params: {
|
||||
groupId: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}): Promise<PageResult<TrafficPoolMember>> {
|
||||
return request("/v1/traffic/pool/v2/group/members", params, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加成员到分组
|
||||
*/
|
||||
export async function addMembersToGroup(
|
||||
groupId: number,
|
||||
poolCompanyIds: number[],
|
||||
): Promise<{ count: number }> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/group/add-members",
|
||||
{ groupId, poolCompanyIds },
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从分组移除成员
|
||||
*/
|
||||
export async function removeMembersFromGroup(
|
||||
groupId: number,
|
||||
poolCompanyIds: number[],
|
||||
): Promise<{ count: number }> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/group/remove-members",
|
||||
{ groupId, poolCompanyIds },
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 流量池成员相关 API ====================
|
||||
|
||||
/**
|
||||
* 获取流量池列表
|
||||
*/
|
||||
export async function getPoolList(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
friendStatus?: number;
|
||||
level?: number;
|
||||
lifecycle?: number;
|
||||
allocateStatus?: number;
|
||||
ownerWechatId?: string;
|
||||
rfmMMin?: number;
|
||||
rfmMMax?: number;
|
||||
}): Promise<PageResult<TrafficPoolMember>> {
|
||||
return request("/v1/traffic/pool/v2/list", params, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量详情
|
||||
*/
|
||||
export async function getPoolDetail(id: number): Promise<
|
||||
TrafficPoolMember & {
|
||||
sources: TrafficSource[];
|
||||
behaviors: TrafficBehavior[];
|
||||
allotRecords: AllotRecord[];
|
||||
}
|
||||
> {
|
||||
return request("/v1/traffic/pool/v2/detail", { id }, "GET", {
|
||||
timeout: 0, // 去除超时限制
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新流量信息
|
||||
*/
|
||||
export async function updatePool(
|
||||
id: number,
|
||||
data: {
|
||||
realName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
birthday?: string;
|
||||
address?: string;
|
||||
company?: string;
|
||||
position?: string;
|
||||
remark?: string;
|
||||
level?: number;
|
||||
intentionLevel?: number;
|
||||
lifecycle?: number;
|
||||
status?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
return request("/v1/traffic/pool/v2/update", { id, ...data }, "PUT");
|
||||
}
|
||||
|
||||
// ==================== 标签相关 API ====================
|
||||
|
||||
/**
|
||||
* 获取标签类目
|
||||
*/
|
||||
export async function getTagCategories(
|
||||
tagType?: number,
|
||||
): Promise<TagCategory[]> {
|
||||
return request("/v1/traffic/pool/v2/tag/categories", { tagType }, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签定义
|
||||
*/
|
||||
export async function getTagDefines(params?: {
|
||||
tagType?: number;
|
||||
categoryId?: number;
|
||||
}): Promise<TagDefine[]> {
|
||||
return request("/v1/traffic/pool/v2/tag/defines", params || {}, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量的标签
|
||||
*/
|
||||
export async function getPoolTags(
|
||||
poolCompanyId: number,
|
||||
tagType?: number,
|
||||
): Promise<TrafficPoolTag[]> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/tag/pool-tags",
|
||||
{ poolCompanyId, tagType },
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为流量添加标签
|
||||
*/
|
||||
export async function addTag(
|
||||
poolCompanyId: number,
|
||||
tagDefineId: number,
|
||||
tagValue?: string,
|
||||
): Promise<{ id: number }> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/tag/add",
|
||||
{ poolCompanyId, tagDefineId, tagValue },
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除流量标签
|
||||
*/
|
||||
export async function removeTag(
|
||||
poolCompanyId: number,
|
||||
tagDefineId: number,
|
||||
): Promise<void> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/tag/remove",
|
||||
{ poolCompanyId, tagDefineId },
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标签引擎同步用户标签
|
||||
*/
|
||||
export async function syncTagsFromEngine(
|
||||
poolCompanyId: number,
|
||||
): Promise<{ syncedCount: number; skippedCount: number; total: number }> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/tag/sync-from-engine",
|
||||
{ poolCompanyId },
|
||||
"POST",
|
||||
{
|
||||
timeout: 0, // 去除超时限制
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 来源和行为相关 API ====================
|
||||
|
||||
/**
|
||||
* 分页获取流量来源
|
||||
*/
|
||||
export async function getPoolSources(params: {
|
||||
poolCompanyId: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}): Promise<{
|
||||
list: TrafficSource[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}> {
|
||||
return request("/v1/traffic/pool/v2/sources", params, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取流量行为轨迹
|
||||
*/
|
||||
export async function getPoolBehaviors(params: {
|
||||
poolCompanyId: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
behaviorType?: number;
|
||||
}): Promise<{
|
||||
list: TrafficBehavior[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}> {
|
||||
return request("/v1/traffic/pool/v2/behaviors", params, "GET");
|
||||
}
|
||||
|
||||
// ==================== 分配相关 API ====================
|
||||
|
||||
/**
|
||||
* 分配流量给客服
|
||||
*/
|
||||
export async function allocatePool(params: {
|
||||
poolCompanyId: number;
|
||||
toWechatId: string;
|
||||
toAccountId?: number;
|
||||
toUserId?: number;
|
||||
expireDays?: number;
|
||||
}): Promise<{ id: number }> {
|
||||
return request("/v1/traffic/pool/v2/allocate", params, "POST");
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收流量分配
|
||||
*/
|
||||
export async function recyclePool(poolCompanyId: number): Promise<void> {
|
||||
return request("/v1/traffic/pool/v2/recycle", { poolCompanyId }, "POST");
|
||||
}
|
||||
|
||||
// ==================== 统计相关 API ====================
|
||||
|
||||
/**
|
||||
* 获取流量池统计数据
|
||||
*/
|
||||
export async function getStatistics(): Promise<TrafficStatistics> {
|
||||
return request("/v1/traffic/pool/v2/statistics", {}, "GET");
|
||||
}
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// 好友状态
|
||||
export const FRIEND_STATUS = {
|
||||
NOT_ADDED: 0, // 未加
|
||||
PENDING: 1, // 待通过
|
||||
PASSED: 2, // 已通过
|
||||
DELETED: 3, // 已删除
|
||||
BE_DELETED: 4, // 被删除
|
||||
};
|
||||
|
||||
export const FRIEND_STATUS_TEXT: Record<number, string> = {
|
||||
[FRIEND_STATUS.NOT_ADDED]: "未加",
|
||||
[FRIEND_STATUS.PENDING]: "待通过",
|
||||
[FRIEND_STATUS.PASSED]: "已通过",
|
||||
[FRIEND_STATUS.DELETED]: "已删除",
|
||||
[FRIEND_STATUS.BE_DELETED]: "被删除",
|
||||
};
|
||||
|
||||
// 客户等级
|
||||
export const LEVEL = {
|
||||
NORMAL: 0, // 普通
|
||||
IMPORTANT: 1, // 重要
|
||||
VIP: 2, // VIP
|
||||
};
|
||||
|
||||
export const LEVEL_TEXT: Record<number, string> = {
|
||||
[LEVEL.NORMAL]: "普通",
|
||||
[LEVEL.IMPORTANT]: "重要",
|
||||
[LEVEL.VIP]: "VIP",
|
||||
};
|
||||
|
||||
// 意向度
|
||||
export const INTENTION_LEVEL = {
|
||||
UNKNOWN: 0, // 未知
|
||||
LOW: 1, // 低
|
||||
MEDIUM: 2, // 中
|
||||
HIGH: 3, // 高
|
||||
};
|
||||
|
||||
export const INTENTION_LEVEL_TEXT: Record<number, string> = {
|
||||
[INTENTION_LEVEL.UNKNOWN]: "未知",
|
||||
[INTENTION_LEVEL.LOW]: "低意向",
|
||||
[INTENTION_LEVEL.MEDIUM]: "中意向",
|
||||
[INTENTION_LEVEL.HIGH]: "高意向",
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
export const LIFECYCLE = {
|
||||
NEW: 1, // 新流量
|
||||
FOLLOWING: 2, // 跟进中
|
||||
CONVERTED: 3, // 已成交
|
||||
SILENT: 4, // 沉默
|
||||
LOST: 5, // 流失
|
||||
};
|
||||
|
||||
export const LIFECYCLE_TEXT: Record<number, string> = {
|
||||
[LIFECYCLE.NEW]: "新流量",
|
||||
[LIFECYCLE.FOLLOWING]: "跟进中",
|
||||
[LIFECYCLE.CONVERTED]: "已成交",
|
||||
[LIFECYCLE.SILENT]: "沉默",
|
||||
[LIFECYCLE.LOST]: "流失",
|
||||
};
|
||||
|
||||
// 来源类型
|
||||
export const SOURCE_TYPE = {
|
||||
FRIEND_ADD: 1, // 好友添加
|
||||
GROUP_MEMBER: 2, // 群成员
|
||||
POSTER: 3, // 海报获客
|
||||
PHONE: 4, // 电话获客
|
||||
ORDER: 5, // 订单获客
|
||||
API: 6, // API导入
|
||||
MANUAL: 7, // 手动导入
|
||||
FISSION: 8, // 裂变活动
|
||||
};
|
||||
|
||||
export const SOURCE_TYPE_TEXT: Record<number, string> = {
|
||||
[SOURCE_TYPE.FRIEND_ADD]: "好友添加",
|
||||
[SOURCE_TYPE.GROUP_MEMBER]: "群成员",
|
||||
[SOURCE_TYPE.POSTER]: "海报获客",
|
||||
[SOURCE_TYPE.PHONE]: "电话获客",
|
||||
[SOURCE_TYPE.ORDER]: "订单获客",
|
||||
[SOURCE_TYPE.API]: "API导入",
|
||||
[SOURCE_TYPE.MANUAL]: "手动导入",
|
||||
[SOURCE_TYPE.FISSION]: "裂变活动",
|
||||
};
|
||||
|
||||
// 标签类型
|
||||
export const TAG_TYPE = {
|
||||
WECHAT: 1, // 微信标签
|
||||
SITE: 2, // 站内标签
|
||||
AI: 3, // AI标签
|
||||
};
|
||||
|
||||
export const TAG_TYPE_TEXT: Record<number, string> = {
|
||||
[TAG_TYPE.WECHAT]: "微信标签",
|
||||
[TAG_TYPE.SITE]: "站内标签",
|
||||
[TAG_TYPE.AI]: "AI标签",
|
||||
};
|
||||
23
Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts
Normal file
23
Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getPoolTags, addTag, removeTag, updatePool } from "../api";
|
||||
import request from "@/api/request";
|
||||
|
||||
export { getPoolTags, addTag, removeTag, updatePool };
|
||||
|
||||
// 获取用户详情(根据 poolCompanyId)
|
||||
export async function fetchUserDetail(id: number) {
|
||||
return request("/v1/traffic/pool/v2/detail", { id }, "GET", {
|
||||
timeout: 0, // 去除超时限制
|
||||
}, 0); // debounceGap 设置为 0,避免防抖拦截
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
export async function updateUserInfo(id: number, data: any) {
|
||||
return updatePool(id, data);
|
||||
}
|
||||
|
||||
// 更新用户RFM评分
|
||||
export async function updateRfm(identifier: string) {
|
||||
return request("/v1/traffic/pool/v2/calculate-rfm", { identifier }, "POST", {
|
||||
timeout: 0, // 去除超时限制
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
// 头部核心卡片
|
||||
.headerCard {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||
padding: 32px 16px 48px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.avatar {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex: 1;
|
||||
|
||||
.nickname {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
|
||||
.levelBadge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用卡片容器
|
||||
.card {
|
||||
margin: -24px 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&.noOverlap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.titleLine {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: #1677ff;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
// 基础信息列表
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
|
||||
.infoItem {
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.value {
|
||||
font-size: 15px;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计面板
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.statBox {
|
||||
background: #f8fbff;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
.statVal {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #1677ff;
|
||||
}
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签样式重构
|
||||
.tagGroups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.tagGroup {
|
||||
.tagGroupTitle {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tagsList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagBase {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tagSite {
|
||||
@extend .tagBase;
|
||||
background: #e6f4ff;
|
||||
color: #0958d9;
|
||||
border: 1px solid #91caff;
|
||||
}
|
||||
|
||||
.tagAi {
|
||||
@extend .tagBase;
|
||||
background: #f9f0ff;
|
||||
color: #531dab;
|
||||
border: 1px solid #d3adf7;
|
||||
}
|
||||
|
||||
.tagWechat {
|
||||
@extend .tagBase;
|
||||
background: #f6ffed;
|
||||
color: #389e0d;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
// 轨迹样式
|
||||
.journeyList {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 1px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.journeyItem {
|
||||
position: relative;
|
||||
padding-bottom: 20px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 6px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #bfbfbf;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
&:first-child::after {
|
||||
background: #1677ff;
|
||||
box-shadow: 0 0 0 4px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.journeyHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.jTitle {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.jTime {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
}
|
||||
.jContent {
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sourceIconText {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ownerList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
|
||||
.ownerLabel {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.ownerItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
border-radius: 8px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
margin-bottom: 16px;
|
||||
:global(.adm-search-bar) {
|
||||
--background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.titleActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.actionBtn {
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
618
Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx
Normal file
618
Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { Avatar, Popup, CheckList, Button as MobileButton, SearchBar, Tag, Empty } from "antd-mobile";
|
||||
import { Spin, message } from "antd";
|
||||
import {
|
||||
UserOutlined,
|
||||
PhoneOutlined,
|
||||
EnvironmentOutlined,
|
||||
TagOutlined,
|
||||
HistoryOutlined,
|
||||
ShareAltOutlined,
|
||||
TrophyOutlined,
|
||||
EditOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
CommentOutlined,
|
||||
NodeIndexOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchUserDetail, addTag, removeTag, updateRfm } from "./api";
|
||||
import {
|
||||
SOURCE_TYPE_TEXT,
|
||||
LEVEL_TEXT,
|
||||
LIFECYCLE_TEXT,
|
||||
INTENTION_LEVEL_TEXT,
|
||||
FRIEND_STATUS_TEXT,
|
||||
getTagDefines,
|
||||
syncTagsFromEngine,
|
||||
getPoolSources,
|
||||
getPoolBehaviors,
|
||||
type TagDefine,
|
||||
} from "../api";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const defaultAvatar = "https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
const { id } = useParams<{ wechatId: string; id: string }>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<any>(null);
|
||||
|
||||
// 状态管理保持原有逻辑
|
||||
const [tagModalVisible, setTagModalVisible] = useState(false);
|
||||
const [tagDefines, setTagDefines] = useState<TagDefine[]>([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [tagLoading, setTagLoading] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
const [rfmLoading, setRfmLoading] = useState(false);
|
||||
|
||||
const [sourcesPage, setSourcesPage] = useState(1);
|
||||
const [sourcesLoading, setSourcesLoading] = useState(false);
|
||||
const [sourcesTotal, setSourcesTotal] = useState(0);
|
||||
const [allSources, setAllSources] = useState<any[]>([]);
|
||||
const [sourcesKeyword, setSourcesKeyword] = useState("");
|
||||
const [sourcesSearchInput, setSourcesSearchInput] = useState("");
|
||||
const sourcesSearchTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const sourcesKeywordInitialized = useRef(false);
|
||||
|
||||
const [behaviorsPage, setBehaviorsPage] = useState(1);
|
||||
const [behaviorsLoading, setBehaviorsLoading] = useState(false);
|
||||
const [behaviorsTotal, setBehaviorsTotal] = useState(0);
|
||||
const [allBehaviors, setAllBehaviors] = useState<any[]>([]);
|
||||
const [behaviorsKeyword, setBehaviorsKeyword] = useState("");
|
||||
const [behaviorsSearchInput, setBehaviorsSearchInput] = useState("");
|
||||
const behaviorsSearchTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const behaviorsKeywordInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadDetail();
|
||||
}, [id]);
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!id) return;
|
||||
// 如果有搜索关键词,不重置搜索状态
|
||||
if (sourcesKeyword || behaviorsKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchUserDetail(parseInt(id));
|
||||
setDetail(res);
|
||||
// 初始化来源和行为数据(详情接口返回的前50条)
|
||||
const initialSources = res.sources || [];
|
||||
const initialBehaviors = res.behaviors || [];
|
||||
setAllSources(initialSources);
|
||||
setAllBehaviors(initialBehaviors);
|
||||
|
||||
// 如果返回了50条,说明可能还有更多,需要获取总数
|
||||
if (initialSources.length >= 50) {
|
||||
try {
|
||||
const sourcesResult = await getPoolSources({
|
||||
poolCompanyId: parseInt(id),
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
setSourcesTotal(sourcesResult.total);
|
||||
setSourcesPage(3);
|
||||
} catch (e) {
|
||||
setSourcesTotal(initialSources.length);
|
||||
setSourcesPage(1);
|
||||
}
|
||||
} else {
|
||||
setSourcesTotal(initialSources.length);
|
||||
setSourcesPage(1);
|
||||
}
|
||||
|
||||
if (initialBehaviors.length >= 50) {
|
||||
try {
|
||||
const behaviorsResult = await getPoolBehaviors({
|
||||
poolCompanyId: parseInt(id),
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
setBehaviorsTotal(behaviorsResult.total);
|
||||
setBehaviorsPage(3);
|
||||
} catch (e) {
|
||||
setBehaviorsTotal(initialBehaviors.length);
|
||||
setBehaviorsPage(1);
|
||||
}
|
||||
} else {
|
||||
setBehaviorsTotal(initialBehaviors.length);
|
||||
setBehaviorsPage(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "获取详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索来源(带防抖)
|
||||
const handleSourcesSearchChange = (value: string) => {
|
||||
setSourcesSearchInput(value);
|
||||
if (sourcesSearchTimer.current) {
|
||||
clearTimeout(sourcesSearchTimer.current);
|
||||
}
|
||||
sourcesSearchTimer.current = setTimeout(() => {
|
||||
setSourcesKeyword(value);
|
||||
setSourcesPage(1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 搜索来源数据
|
||||
const searchSources = async (keyword: string, page: number = 1) => {
|
||||
if (!id) return;
|
||||
setSourcesLoading(true);
|
||||
try {
|
||||
const result = await getPoolSources({
|
||||
poolCompanyId: parseInt(id),
|
||||
page,
|
||||
pageSize: keyword ? 100 : 50,
|
||||
keyword,
|
||||
});
|
||||
if (page === 1) {
|
||||
setAllSources(result.list);
|
||||
} else {
|
||||
// 去重合并
|
||||
const existingIds = new Set(allSources.map(s => s.id));
|
||||
const newList = result.list.filter(s => !existingIds.has(s.id));
|
||||
setAllSources([...allSources, ...newList]);
|
||||
}
|
||||
setSourcesTotal(result.total);
|
||||
setSourcesPage(page);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "搜索失败");
|
||||
} finally {
|
||||
setSourcesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多来源
|
||||
const loadMoreSources = async () => {
|
||||
if (!id || sourcesLoading) return;
|
||||
const nextPage = sourcesPage + 1;
|
||||
await searchSources(sourcesKeyword, nextPage);
|
||||
};
|
||||
|
||||
// 监听搜索关键词变化
|
||||
useEffect(() => {
|
||||
if (!sourcesKeywordInitialized.current) {
|
||||
sourcesKeywordInitialized.current = true;
|
||||
return;
|
||||
}
|
||||
if (id && sourcesKeyword !== "") {
|
||||
searchSources(sourcesKeyword, 1);
|
||||
} else if (id && sourcesKeyword === "") {
|
||||
loadDetail();
|
||||
}
|
||||
}, [sourcesKeyword]);
|
||||
|
||||
// 搜索行为(带防抖)
|
||||
const handleBehaviorsSearchChange = (value: string) => {
|
||||
setBehaviorsSearchInput(value);
|
||||
if (behaviorsSearchTimer.current) {
|
||||
clearTimeout(behaviorsSearchTimer.current);
|
||||
}
|
||||
behaviorsSearchTimer.current = setTimeout(() => {
|
||||
setBehaviorsKeyword(value);
|
||||
setBehaviorsPage(1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 搜索行为数据
|
||||
const searchBehaviors = async (keyword: string, page: number = 1) => {
|
||||
if (!id) return;
|
||||
setBehaviorsLoading(true);
|
||||
try {
|
||||
const result = await getPoolBehaviors({
|
||||
poolCompanyId: parseInt(id),
|
||||
page,
|
||||
pageSize: 100,
|
||||
keyword,
|
||||
});
|
||||
if (page === 1) {
|
||||
setAllBehaviors(result.list);
|
||||
} else {
|
||||
const existingIds = new Set(allBehaviors.map(b => b.id));
|
||||
const newList = result.list.filter(b => !existingIds.has(b.id));
|
||||
setAllBehaviors([...allBehaviors, ...newList]);
|
||||
}
|
||||
setBehaviorsTotal(result.total);
|
||||
setBehaviorsPage(page);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "搜索失败");
|
||||
} finally {
|
||||
setBehaviorsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多行为
|
||||
const loadMoreBehaviors = async () => {
|
||||
if (!id || behaviorsLoading) return;
|
||||
const nextPage = behaviorsPage + 1;
|
||||
await searchBehaviors(behaviorsKeyword, nextPage);
|
||||
};
|
||||
|
||||
// 监听搜索关键词变化
|
||||
useEffect(() => {
|
||||
if (!behaviorsKeywordInitialized.current) {
|
||||
behaviorsKeywordInitialized.current = true;
|
||||
return;
|
||||
}
|
||||
if (id && behaviorsKeyword !== "") {
|
||||
searchBehaviors(behaviorsKeyword, 1);
|
||||
} else if (id && behaviorsKeyword === "") {
|
||||
loadDetail();
|
||||
}
|
||||
}, [behaviorsKeyword]);
|
||||
|
||||
// 标签处理逻辑
|
||||
const openTagModal = async () => {
|
||||
setTagModalVisible(true);
|
||||
setTagLoading(true);
|
||||
try {
|
||||
const defines = await getTagDefines({ tagType: 2 });
|
||||
setTagDefines(defines);
|
||||
const existingTagIds = detail?.tags?.filter((t: any) => t.tagType === 2).map((t: any) => t.tagDefineId) || [];
|
||||
setSelectedTagIds(existingTagIds);
|
||||
} catch (error) {
|
||||
message.error("获取标签列表失败");
|
||||
} finally {
|
||||
setTagLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncTags = async () => {
|
||||
if (!id || syncLoading) return;
|
||||
setSyncLoading(true);
|
||||
try {
|
||||
const result = await syncTagsFromEngine(parseInt(id));
|
||||
message.success(`同步成功:已同步 ${result.syncedCount} 个标签`);
|
||||
loadDetail();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "同步失败");
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRfm = async () => {
|
||||
if (!detail?.identifier || rfmLoading) return;
|
||||
setRfmLoading(true);
|
||||
try {
|
||||
const result = await updateRfm(detail.identifier);
|
||||
message.success("RFM评分更新成功");
|
||||
// 重新加载详情以获取最新的RFM数据
|
||||
loadDetail();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "RFM更新失败");
|
||||
} finally {
|
||||
setRfmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTagChanges = async () => {
|
||||
if (!id || !detail) return;
|
||||
const poolCompanyId = parseInt(id);
|
||||
const existingTagIds = detail.tags
|
||||
?.filter((t: any) => t.tagType === 2)
|
||||
?.map((t: any) => t.tagDefineId) || [];
|
||||
|
||||
const toAdd = selectedTagIds.filter(tagId => !existingTagIds.includes(tagId));
|
||||
const toRemove = existingTagIds.filter((tagId: number) => !selectedTagIds.includes(tagId));
|
||||
|
||||
setTagLoading(true);
|
||||
try {
|
||||
for (const tagDefineId of toAdd) {
|
||||
await addTag(poolCompanyId, tagDefineId);
|
||||
}
|
||||
for (const tagDefineId of toRemove) {
|
||||
await removeTag(poolCompanyId, tagDefineId);
|
||||
}
|
||||
message.success("标签更新成功");
|
||||
setTagModalVisible(false);
|
||||
loadDetail();
|
||||
} catch (error) {
|
||||
message.error("标签更新失败");
|
||||
} finally {
|
||||
setTagLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !detail) {
|
||||
return <Layout><div className={styles.loadingContainer}><Spin tip="加载中..." /></div></Layout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className={styles.container}>
|
||||
<NavCommon title="用户详情" />
|
||||
|
||||
{/* 顶部核心卡片 */}
|
||||
<div className={styles.headerCard}>
|
||||
<Avatar src={detail?.avatar || defaultAvatar} className={styles.avatar} style={{ '--size': '64px' }} />
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.nickname}>
|
||||
{detail?.nickname || '未知用户'}
|
||||
<Tag color='warning' fill='outline' style={{ fontSize: '10px' }}>
|
||||
{LEVEL_TEXT[detail?.level] || '普通'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.tags}>
|
||||
<span className={styles.levelBadge}>{detail?.wechatAlias || detail?.wechatId || '无微信号'}</span>
|
||||
<span className={styles.levelBadge}>{detail?.region || '未知地区'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计面板卡片 */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<span><TrophyOutlined style={{ color: '#faad14', marginRight: 8 }} />互动统计</span>
|
||||
<div className={styles.titleActions}>
|
||||
<span className={styles.actionBtn} onClick={handleUpdateRfm}>
|
||||
<SyncOutlined spin={rfmLoading} /> 更新RFM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.statBox}>
|
||||
<span className={styles.statVal}>{detail?.totalMsgCount || 0}</span>
|
||||
<span className={styles.statLabel}>消息数</span>
|
||||
</div>
|
||||
<div className={styles.statBox}>
|
||||
<span className={styles.statVal}>{detail?.totalOrderCount || 0}</span>
|
||||
<span className={styles.statLabel}>订单数</span>
|
||||
</div>
|
||||
<div className={styles.statBox}>
|
||||
<span className={styles.statVal}>{detail?.rfmScore?.total || 0}</span>
|
||||
<span className={styles.statLabel}>RFM得分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RFM详细得分 */}
|
||||
<div className={styles.infoGrid} style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>R-最近购买</div>
|
||||
<div className={styles.value}>{detail?.rfmScore?.R || 0}分</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>F-购买频次</div>
|
||||
<div className={styles.value}>{detail?.rfmScore?.F || 0}分</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>M-购买金额</div>
|
||||
<div className={styles.value}>{detail?.rfmScore?.M || 0}分</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基础信息卡片 */}
|
||||
<div className={`${styles.card} ${styles.noOverlap}`}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<span><UserOutlined style={{ color: '#1677ff', marginRight: 8 }} />基础资料</span>
|
||||
</div>
|
||||
<div className={styles.infoGrid}>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>加粉状态</div>
|
||||
<div className={styles.value}>{FRIEND_STATUS_TEXT[detail?.friendStatus] || '未知'}</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>客户周期</div>
|
||||
<div className={styles.value}>{LIFECYCLE_TEXT[detail?.lifecycle] || '新流量'}</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>意向等级</div>
|
||||
<div className={styles.value}>{INTENTION_LEVEL_TEXT[detail?.intentionLevel] || '未知'}</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>最后互动</div>
|
||||
<div className={styles.value}>{detail?.lastInteractTime ? new Date(detail.lastInteractTime * 1000).toLocaleDateString() : '从未'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签管理卡片 */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<span><TagOutlined style={{ color: '#722ed1', marginRight: 8 }} />标签画像</span>
|
||||
<div className={styles.titleActions}>
|
||||
<span className={styles.actionBtn} onClick={handleSyncTags}><SyncOutlined spin={syncLoading} /> 同步</span>
|
||||
<span className={styles.actionBtn} onClick={openTagModal}><EditOutlined /> 编辑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tagGroups}>
|
||||
{/* 微信标签 */}
|
||||
{detail?.tags?.some((t: any) => t.tagType === 1) && (
|
||||
<div className={styles.tagGroup}>
|
||||
<div className={styles.tagGroupTitle}><CommentOutlined /> 微信标签</div>
|
||||
<div className={styles.tagsList}>
|
||||
{detail?.tags?.filter((t: any) => t.tagType === 1).map((tag: any) => (
|
||||
<span key={tag.id} className={styles.tagWechat}>{tag.tagName}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 站内标签 */}
|
||||
<div className={styles.tagGroup}>
|
||||
<div className={styles.tagGroupTitle}><EditOutlined /> 站内标签</div>
|
||||
<div className={styles.tagsList}>
|
||||
{detail?.tags?.filter((t: any) => t.tagType === 2).length > 0 ? (
|
||||
detail?.tags?.filter((t: any) => t.tagType === 2).map((tag: any) => (
|
||||
<span key={tag.id} className={styles.tagSite}>{tag.tagName}</span>
|
||||
))
|
||||
) : <span style={{ color: '#bfbfbf', fontSize: '12px' }}>暂无标签</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* AI标签 */}
|
||||
{detail?.tags?.some((t: any) => t.tagType === 3) && (
|
||||
<div className={styles.tagGroup}>
|
||||
<div className={styles.tagGroupTitle}><NodeIndexOutlined /> AI标签</div>
|
||||
<div className={styles.tagsList}>
|
||||
{detail?.tags?.filter((t: any) => t.tagType === 3).map((tag: any) => (
|
||||
<span key={tag.id} className={styles.tagAi}>{tag.tagName}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行为轨迹卡片 */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<span><HistoryOutlined style={{ color: '#eb2f96', marginRight: 8 }} />行为轨迹</span>
|
||||
</div>
|
||||
<div className={styles.searchWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索行为..."
|
||||
value={behaviorsSearchInput}
|
||||
onChange={handleBehaviorsSearchChange}
|
||||
onClear={() => {
|
||||
setBehaviorsSearchInput("");
|
||||
setBehaviorsKeyword("");
|
||||
setBehaviorsPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.journeyList}>
|
||||
{allBehaviors.length > 0 ? (
|
||||
allBehaviors.map((b, index) => (
|
||||
<div key={index} className={styles.journeyItem}>
|
||||
<div className={styles.journeyHeader}>
|
||||
<span className={styles.jTitle}>{b.behaviorName}</span>
|
||||
<span className={styles.jTime}><ClockCircleOutlined /> {b.behaviorTimeFormatted || '刚刚'}</span>
|
||||
</div>
|
||||
<div className={styles.jContent}>{b.targetName || '执行了相关操作'}</div>
|
||||
</div>
|
||||
))
|
||||
) : <Empty description="暂无轨迹" />}
|
||||
</div>
|
||||
{behaviorsTotal > allBehaviors.length && (
|
||||
<MobileButton
|
||||
block
|
||||
className={styles.loadMore}
|
||||
fill='none'
|
||||
color='primary'
|
||||
loading={behaviorsLoading}
|
||||
onClick={loadMoreBehaviors}
|
||||
>
|
||||
加载更多
|
||||
</MobileButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 来源追溯卡片 */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<span><ShareAltOutlined style={{ color: '#2f54eb', marginRight: 8 }} />来源追溯</span>
|
||||
</div>
|
||||
<div className={styles.searchWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索来源..."
|
||||
value={sourcesSearchInput}
|
||||
onChange={handleSourcesSearchChange}
|
||||
onClear={() => {
|
||||
setSourcesSearchInput("");
|
||||
setSourcesKeyword("");
|
||||
setSourcesPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.journeyList}>
|
||||
{allSources.length > 0 ? (
|
||||
allSources.map((s, index) => (
|
||||
<div key={index} className={styles.journeyItem}>
|
||||
<div className={styles.journeyHeader}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{s.sourceType === 2 && s.chatroomInfo?.chatroomAvatar ? (
|
||||
<Avatar src={s.chatroomInfo.chatroomAvatar} style={{ '--size': '24px', borderRadius: '4px' }} />
|
||||
) : (s.sourceType === 1 && s.sourceAvatar ? (
|
||||
<Avatar src={s.sourceAvatar} style={{ '--size': '24px', borderRadius: '50%' }} />
|
||||
) : (
|
||||
<div className={styles.sourceIconText}>{SOURCE_TYPE_TEXT[s.sourceType]?.charAt(0)}</div>
|
||||
))}
|
||||
<span className={styles.jTitle}>{SOURCE_TYPE_TEXT[s.sourceType]}</span>
|
||||
</div>
|
||||
<span className={styles.jTime}>{s.createTimeFormatted}</span>
|
||||
</div>
|
||||
<div className={styles.jContent}>
|
||||
{s.sourceType === 2 ? (
|
||||
<div>
|
||||
<div>群聊:{s.chatroomInfo?.chatroomName}</div>
|
||||
{s.chatroomOwners && s.chatroomOwners.length > 0 && (
|
||||
<div className={styles.ownerList}>
|
||||
<span className={styles.ownerLabel}>归属:</span>
|
||||
{s.chatroomOwners.map((owner: any, idx: number) => (
|
||||
<div key={idx} className={styles.ownerItem}>
|
||||
<Avatar src={owner.ownerAvatar || defaultAvatar} style={{ '--size': '16px', borderRadius: '50%' }} />
|
||||
<span>{owner.ownerNickname}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
s.sourceName
|
||||
)}
|
||||
{s.displayId && <div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>ID: {s.displayId}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : <Empty description="暂无来源" />}
|
||||
</div>
|
||||
{sourcesTotal > allSources.length && (
|
||||
<MobileButton
|
||||
block
|
||||
className={styles.loadMore}
|
||||
fill='none'
|
||||
color='primary'
|
||||
loading={sourcesLoading}
|
||||
onClick={loadMoreSources}
|
||||
>
|
||||
加载更多
|
||||
</MobileButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签编辑弹窗 */}
|
||||
<Popup
|
||||
visible={tagModalVisible}
|
||||
onMaskClick={() => setTagModalVisible(false)}
|
||||
bodyStyle={{ borderTopLeftRadius: '16px', borderTopRightRadius: '16px', minHeight: '40vh' }}
|
||||
>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div className={styles.sectionTitle}>编辑站内标签</div>
|
||||
{tagLoading ? <Spin /> : (
|
||||
<CheckList
|
||||
multiple
|
||||
value={selectedTagIds}
|
||||
onChange={(val) => setSelectedTagIds(val as number[])}
|
||||
>
|
||||
{tagDefines.map(item => (
|
||||
<CheckList.Item key={item.id} value={item.id}>{item.tagName}</CheckList.Item>
|
||||
))}
|
||||
</CheckList>
|
||||
)}
|
||||
<MobileButton
|
||||
block
|
||||
color='primary'
|
||||
style={{ marginTop: '20px' }}
|
||||
loading={tagLoading}
|
||||
onClick={saveTagChanges}
|
||||
>
|
||||
完成
|
||||
</MobileButton>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPoolDetail;
|
||||
@@ -1,56 +1,75 @@
|
||||
import request from "@/api/request";
|
||||
import { createGroup, type RuleCondition, type RuleConfig } from "../api";
|
||||
|
||||
// 创建流量包
|
||||
export interface CreateTrafficPackageParams {
|
||||
name: string;
|
||||
groupName: string;
|
||||
description?: string;
|
||||
remarks?: string;
|
||||
filterConditions: any[];
|
||||
userIds: string[];
|
||||
groupIcon?: string;
|
||||
groupColor?: string;
|
||||
ruleType: number; // 1=动态规则,2=手动添加
|
||||
ruleConfig?: RuleConfig;
|
||||
memberIds?: number[]; // 手动添加时的成员ID列表
|
||||
}
|
||||
|
||||
export interface CreateTrafficPackageResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
id: number;
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
export async function createTrafficPackage(
|
||||
params: CreateTrafficPackageParams,
|
||||
): Promise<CreateTrafficPackageResponse> {
|
||||
return request("/v1/traffic/pool/create", params, "POST");
|
||||
// 调用V2接口创建分组
|
||||
return createGroup(params);
|
||||
}
|
||||
|
||||
// 获取用户列表(根据筛选条件)
|
||||
export interface GetUsersByFilterParams {
|
||||
conditions: any[];
|
||||
ruleConfig: RuleConfig & { keyword?: string };
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
id: number;
|
||||
identifier: string;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
wechatId: string | null;
|
||||
wechatAlias: string | null;
|
||||
phone: string | null;
|
||||
region: string;
|
||||
gender: number;
|
||||
rfmScore: {
|
||||
R: number;
|
||||
F: number;
|
||||
M: number;
|
||||
total: number;
|
||||
};
|
||||
tags: string[];
|
||||
rfmScore: number;
|
||||
lastActive: string;
|
||||
consumption: number;
|
||||
lastInteractTime: number;
|
||||
totalMsgCount: number;
|
||||
totalOrderAmount: string;
|
||||
lifecycle: number;
|
||||
intentionLevel: number;
|
||||
}
|
||||
|
||||
export interface GetUsersByFilterResponse {
|
||||
list: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export async function getUsersByFilter(
|
||||
params: GetUsersByFilterParams,
|
||||
): Promise<GetUsersByFilterResponse> {
|
||||
return request("/v1/traffic/pool/users/filter", params, "POST");
|
||||
// 使用 POST 请求,因为 ruleConfig 是复杂对象
|
||||
return request("/v1/traffic/pool/v2/preview-users", params, "POST");
|
||||
}
|
||||
|
||||
// 获取预设方案列表
|
||||
// 获取预设方案列表(基于系统分组)
|
||||
export interface PresetScheme {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -61,70 +80,58 @@ export interface PresetScheme {
|
||||
}
|
||||
|
||||
export async function getPresetSchemes(): Promise<PresetScheme[]> {
|
||||
// 模拟数据
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
id: "scheme_1",
|
||||
name: "高价值客户方案",
|
||||
description: "针对高消费、高活跃度的客户群体",
|
||||
conditions: [
|
||||
{ id: "rfm_high", type: "rfm", label: "RFM评分", value: "high" },
|
||||
{
|
||||
id: "consumption_high",
|
||||
type: "consumption",
|
||||
label: "消费能力",
|
||||
value: "high",
|
||||
},
|
||||
],
|
||||
userCount: 1250,
|
||||
color: "#ff4d4f",
|
||||
},
|
||||
{
|
||||
id: "scheme_2",
|
||||
name: "新用户激活方案",
|
||||
description: "针对新注册用户的激活策略",
|
||||
conditions: [
|
||||
{ id: "new_user", type: "tag", label: "新用户", value: true },
|
||||
{
|
||||
id: "low_activity",
|
||||
type: "activity",
|
||||
label: "活跃度",
|
||||
value: "low",
|
||||
},
|
||||
],
|
||||
userCount: 890,
|
||||
color: "#52c41a",
|
||||
},
|
||||
{
|
||||
id: "scheme_3",
|
||||
name: "流失挽回方案",
|
||||
description: "针对流失风险用户的挽回策略",
|
||||
conditions: [
|
||||
{ id: "churn_risk", type: "tag", label: "流失风险", value: true },
|
||||
{
|
||||
id: "last_active",
|
||||
type: "time",
|
||||
label: "最后活跃",
|
||||
value: "30天前",
|
||||
},
|
||||
],
|
||||
userCount: 567,
|
||||
color: "#faad14",
|
||||
},
|
||||
]);
|
||||
}, 500);
|
||||
});
|
||||
// return request("/v1/traffic/pool/schemes", {}, "GET");
|
||||
try {
|
||||
// 获取所有分组作为推荐方案
|
||||
const groups = await request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||
|
||||
// 去重(根据 id)并过滤掉没有规则配置的分组
|
||||
const uniqueGroups: any[] = [];
|
||||
const seenIds = new Set<number>();
|
||||
|
||||
for (const group of groups) {
|
||||
if (!seenIds.has(group.id)) {
|
||||
seenIds.add(group.id);
|
||||
uniqueGroups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为PresetScheme格式,优先显示系统分组
|
||||
const sortedGroups = uniqueGroups.sort((a: any, b: any) => {
|
||||
// 系统分组排前面
|
||||
if (a.isSystem !== b.isSystem) return b.isSystem - a.isSystem;
|
||||
// 同类型按 id 排序
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group: any) => ({
|
||||
id: String(group.id),
|
||||
name: group.groupName,
|
||||
description: group.description || (group.isSystem ? `系统预设:${group.groupName}` : `自定义:${group.groupName}`),
|
||||
conditions: group.ruleConfig?.conditions || [],
|
||||
userCount: group.memberCount || 0,
|
||||
color: group.groupColor || (group.isSystem ? "#1677ff" : "#52c41a"),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("获取预设方案失败:", error);
|
||||
// 返回空数组或默认方案
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取行业选项(固定筛选项)
|
||||
export interface IndustryOption {
|
||||
// 获取筛选字段元数据
|
||||
export interface FilterField {
|
||||
field: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
type: 'select' | 'input' | 'number' | 'province' | 'friend_search';
|
||||
options?: { label: string; value: any }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export async function getIndustryOptions(): Promise<IndustryOption[]> {
|
||||
return request("/v1/traffic/pool/industries", {}, "GET");
|
||||
export async function getFilterFields(): Promise<FilterField[]> {
|
||||
return request("/v1/traffic/pool/v2/filter-fields", {}, "GET");
|
||||
}
|
||||
|
||||
// 兼容旧接口名称
|
||||
export async function getIndustryOptions(): Promise<FilterField[]> {
|
||||
return getFilterFields();
|
||||
}
|
||||
|
||||
@@ -1,129 +1,123 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.schemeRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addSchemeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
height: 32px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rfmGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rfmItem {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rfmLabel {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rfmValue {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ageRange {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.consumptionLevel {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.levelTag {
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tagGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 8px 12px;
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.addConditionBtn {
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
border-style: dashed;
|
||||
border-color: #d9d9d9;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #007aff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.generateBtn {
|
||||
margin-top: 16px;
|
||||
.selectWrapper {
|
||||
:global(.ant-select-selector) {
|
||||
border-radius: 12px !important;
|
||||
background: #f9fafb !important;
|
||||
border: 1px solid transparent !important;
|
||||
height: 48px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px !important;
|
||||
|
||||
&:hover, &:focus {
|
||||
border-color: #007aff !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-placeholder) {
|
||||
line-height: 48px !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
line-height: 48px !important;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.tagGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tagItem {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #e5e7eb;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed #d1d5db;
|
||||
color: #007aff;
|
||||
background: #f0f7ff;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: #e0efff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
}
|
||||
|
||||
.friendBtn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed #d1d5db;
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: #e6ffe0;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Button } from "antd-mobile";
|
||||
import { Button } from "antd-mobile";
|
||||
import { Select } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { AddOutline, UserAddOutline } from "antd-mobile-icons";
|
||||
import CustomConditionModal from "./CustomConditionModal";
|
||||
import FriendSearchModal from "./FriendSearchModal";
|
||||
import ConditionList from "./ConditionList";
|
||||
import styles from "./AudienceFilter.module.scss";
|
||||
import {
|
||||
getIndustryOptions,
|
||||
getPresetSchemes,
|
||||
IndustryOption,
|
||||
FilterField,
|
||||
PresetScheme,
|
||||
} from "../api";
|
||||
|
||||
interface Friend {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface FilterCondition {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
value: any;
|
||||
operator?: string;
|
||||
field?: string;
|
||||
displayValue?: string;
|
||||
friends?: Friend[]; // 选中的好友列表
|
||||
}
|
||||
|
||||
interface AudienceFilterProps {
|
||||
@@ -30,165 +42,137 @@ const AudienceFilter: React.FC<AudienceFilterProps> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||
const [industryOptions, setIndustryOptions] = useState<IndustryOption[]>([]);
|
||||
const [showFriendModal, setShowFriendModal] = useState(false);
|
||||
const [industryOptions, setIndustryOptions] = useState<FilterField[]>([]);
|
||||
const [presetSchemes, setPresetSchemes] = useState<PresetScheme[]>([]);
|
||||
const [selectedIndustry, setSelectedIndustry] = useState<
|
||||
string | number | undefined
|
||||
>(undefined);
|
||||
const [selectedScheme, setSelectedScheme] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [selectedScheme, setSelectedScheme] = useState<string | undefined>(undefined);
|
||||
|
||||
// 加载行业选项和方案列表
|
||||
useEffect(() => {
|
||||
getIndustryOptions()
|
||||
.then(res => setIndustryOptions(res || []))
|
||||
.catch(() => setIndustryOptions([]));
|
||||
|
||||
getPresetSchemes()
|
||||
.then(res => setPresetSchemes(res || []))
|
||||
.catch(() => setPresetSchemes([]));
|
||||
getIndustryOptions().then(res => setIndustryOptions(res || []));
|
||||
getPresetSchemes().then(res => setPresetSchemes(res || []));
|
||||
}, []);
|
||||
|
||||
const handleAddCondition = (condition: FilterCondition) => {
|
||||
const newConditions = [...conditions, condition];
|
||||
onChange(newConditions);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (id: string) => {
|
||||
const newConditions = conditions.filter(c => c.id !== id);
|
||||
onChange(newConditions);
|
||||
};
|
||||
|
||||
const handleUpdateCondition = (id: string, value: any) => {
|
||||
const newConditions = conditions.map(c =>
|
||||
c.id === id ? { ...c, value } : c,
|
||||
);
|
||||
onChange(newConditions);
|
||||
};
|
||||
|
||||
const handleSchemeChange = (schemeId: string) => {
|
||||
setSelectedScheme(schemeId);
|
||||
if (schemeId) {
|
||||
// 找到选中的方案并应用其条件
|
||||
const scheme = presetSchemes.find(s => s.id === schemeId);
|
||||
if (scheme) {
|
||||
onChange(scheme.conditions);
|
||||
}
|
||||
} else {
|
||||
// 清空方案选择时,清空条件
|
||||
onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddScheme = () => {
|
||||
// 这里可以打开添加方案的弹窗或跳转到方案管理页面
|
||||
console.log("添加新方案");
|
||||
// 获取已选中的好友(从已有条件中提取)
|
||||
const getSelectedFriends = (): Friend[] => {
|
||||
const friendCondition = conditions.find(c => c.field === 'friendIds');
|
||||
return friendCondition?.friends || [];
|
||||
};
|
||||
|
||||
// 处理好友选择确认
|
||||
const handleFriendsConfirm = (friends: Friend[]) => {
|
||||
// 移除旧的好友条件
|
||||
const newConditions = conditions.filter(c => c.field !== 'friendIds');
|
||||
|
||||
if (friends.length > 0) {
|
||||
// 添加新的好友条件
|
||||
const friendCondition: FilterCondition = {
|
||||
id: 'friendIds',
|
||||
type: 'field',
|
||||
field: 'friendIds',
|
||||
label: '指定好友',
|
||||
operator: 'in',
|
||||
value: friends.map(f => f.id),
|
||||
displayValue: `已选 ${friends.length} 人`,
|
||||
friends: friends,
|
||||
};
|
||||
newConditions.push(friendCondition);
|
||||
}
|
||||
|
||||
onChange(newConditions);
|
||||
};
|
||||
|
||||
// 处理条件添加(排除好友搜索类型,因为有专门的按钮)
|
||||
const handleAddCondition = (condition: FilterCondition) => {
|
||||
// 如果是搜索好友条件,打开好友选择弹窗
|
||||
if (condition.field === 'keyword' && condition.fieldType === 'friend_search') {
|
||||
setShowFriendModal(true);
|
||||
return;
|
||||
}
|
||||
onChange([...conditions, condition]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>人群筛选</div>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>人群方案</div>
|
||||
<div className={styles.selectWrapper}>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder="选择预设方案"
|
||||
value={selectedScheme}
|
||||
onChange={handleSchemeChange}
|
||||
options={presetSchemes.map(scheme => ({
|
||||
label: `${scheme.name} (${scheme.userCount}人)`,
|
||||
value: scheme.id,
|
||||
}))}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 方案推荐选择 */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>方案推荐</div>
|
||||
<div className={styles.schemeRow}>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
placeholder="选择预设方案"
|
||||
value={selectedScheme}
|
||||
onChange={handleSchemeChange}
|
||||
options={presetSchemes.map(scheme => ({
|
||||
label: `${scheme.name} (${scheme.userCount}人)`,
|
||||
value: scheme.id,
|
||||
}))}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
onClick={handleAddScheme}
|
||||
className={styles.addSchemeBtn}
|
||||
>
|
||||
<PlusOutlined />
|
||||
添加方案
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>自定义条件</div>
|
||||
<ConditionList
|
||||
conditions={conditions}
|
||||
onRemove={(condition) => {
|
||||
const newConditions = conditions.filter(c =>
|
||||
c.field ? c.field !== condition.field : c.id !== condition.id
|
||||
);
|
||||
onChange(newConditions);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
fill="none"
|
||||
className={styles.addBtn}
|
||||
onClick={() => setShowCustomModal(true)}
|
||||
>
|
||||
<AddOutline style={{ marginRight: 4 }} /> 添加筛选条件
|
||||
</Button>
|
||||
<Button
|
||||
fill="none"
|
||||
className={styles.friendBtn}
|
||||
onClick={() => setShowFriendModal(true)}
|
||||
>
|
||||
<UserAddOutline style={{ marginRight: 4 }} /> 选择好友
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 条件筛选区域 - 当未选择方案时显示 */}
|
||||
{!selectedScheme && (
|
||||
<>
|
||||
{/* 行业筛选(固定项,接口获取选项) */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>行业</div>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder="选择行业"
|
||||
value={selectedIndustry}
|
||||
onChange={value => setSelectedIndustry(value)}
|
||||
options={industryOptions.map(opt => ({
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
}))}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>常用业务标签</div>
|
||||
<div className={styles.tagGrid}>
|
||||
{["高价值", "新客", "活跃", "待唤醒", "价格敏感", "忠诚"].map((tag, idx) => (
|
||||
<div key={idx} className={styles.tagItem}>{tag}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>标签筛选</div>
|
||||
<div className={styles.tagGrid}>
|
||||
{[
|
||||
{ name: "高价值用户", color: "#1677ff" },
|
||||
{ name: "新用户", color: "#52c41a" },
|
||||
{ name: "活跃用户", color: "#faad14" },
|
||||
{ name: "流失风险", color: "#eb2f96" },
|
||||
{ name: "复购率高", color: "#722ed1" },
|
||||
{ name: "高潜力", color: "#eb2f96" },
|
||||
{ name: "已沉睡", color: "#bfbfbf" },
|
||||
{ name: "价格敏感", color: "#13c2c2" },
|
||||
].map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.tag}
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义条件列表 */}
|
||||
<ConditionList
|
||||
conditions={conditions}
|
||||
onRemove={handleRemoveCondition}
|
||||
onUpdate={handleUpdateCondition}
|
||||
/>
|
||||
|
||||
{/* 添加自定义条件 */}
|
||||
<Button
|
||||
fill="outline"
|
||||
onClick={() => setShowCustomModal(true)}
|
||||
className={styles.addConditionBtn}
|
||||
>
|
||||
+ 添加自定义条件
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 自定义条件弹窗 */}
|
||||
<CustomConditionModal
|
||||
visible={showCustomModal}
|
||||
onClose={() => setShowCustomModal(false)}
|
||||
onAdd={handleAddCondition}
|
||||
/>
|
||||
|
||||
<FriendSearchModal
|
||||
visible={showFriendModal}
|
||||
onClose={() => setShowFriendModal(false)}
|
||||
onConfirm={handleFriendsConfirm}
|
||||
existingConditions={conditions.filter(c => c.field !== 'friendIds')}
|
||||
selectedFriends={getSelectedFriends()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,60 +1,75 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
// 输入框样式覆盖
|
||||
:global(.adm-input), :global(.adm-text-area) {
|
||||
--font-size: 14px;
|
||||
background: #f8fafc !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 10px 12px !important;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.adm-input-element), :global(.adm-text-area-element) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:global(.adm-input-element::placeholder), :global(.adm-text-area-element::placeholder) {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
.sectionTitle {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 18px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #222;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
color: #ef4444;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
:global(.adm-form-item) {
|
||||
padding: 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.adm-form-item-label) {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Card, Form, Input } from "antd-mobile";
|
||||
import { Form, Input, TextArea } from "antd-mobile";
|
||||
import styles from "./BasicInfo.module.scss";
|
||||
|
||||
interface BasicInfoProps {
|
||||
@@ -18,46 +18,49 @@ const BasicInfo: React.FC<BasicInfoProps> = ({ data, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.title}>基本信息</div>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.sectionTitle}>计划设置</div>
|
||||
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className={styles.label}>
|
||||
流量包名称<span className={styles.required}>*</span>
|
||||
计划名称<span className={styles.required}>*</span>
|
||||
</span>
|
||||
}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
placeholder="输入流量包名称"
|
||||
placeholder="请输入计划名称"
|
||||
value={data.name}
|
||||
onChange={value => handleChange("name", value)}
|
||||
className={styles.input}
|
||||
clearable
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={<span className={styles.label}>描述</span>}>
|
||||
<Form.Item
|
||||
label={<span className={styles.label}>计划描述</span>}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入流量包描述"
|
||||
placeholder="请输入计划描述 (可选)"
|
||||
value={data.description}
|
||||
onChange={value => handleChange("description", value)}
|
||||
className={styles.input}
|
||||
clearable
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={<span className={styles.label}>备注</span>}>
|
||||
<Input
|
||||
placeholder="输入备注信息 (选填)"
|
||||
<Form.Item
|
||||
label={<span className={styles.label}>详细备注</span>}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入备注说明..."
|
||||
value={data.remarks}
|
||||
onChange={value => handleChange("remarks", value)}
|
||||
className={styles.textarea}
|
||||
rows={3}
|
||||
rows={4}
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,52 +1,49 @@
|
||||
.container {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.conditionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.conditionItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f3f4f6;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f9fafb;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.conditionContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.conditionLabel {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.conditionValue {
|
||||
font-size: 14px;
|
||||
.label {
|
||||
color: #6b7280;
|
||||
margin-right: 10px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
color: #ff4d4f;
|
||||
padding: 4px;
|
||||
color: #9ca3af;
|
||||
padding: 6px;
|
||||
--font-size: 20px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
&:active {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,44 @@
|
||||
import React from "react";
|
||||
import { Button } from "antd-mobile";
|
||||
import { DeleteOutline } from "antd-mobile-icons";
|
||||
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||
import styles from "./ConditionList.module.scss";
|
||||
|
||||
interface FilterCondition {
|
||||
id: string;
|
||||
id?: string;
|
||||
type: string;
|
||||
field?: string;
|
||||
label: string;
|
||||
value: any;
|
||||
operator?: string;
|
||||
displayValue?: any;
|
||||
}
|
||||
|
||||
interface ConditionListProps {
|
||||
conditions: FilterCondition[];
|
||||
onRemove: (id: string) => void;
|
||||
onUpdate: (id: string, value: any) => void;
|
||||
onRemove: (condition: any) => void;
|
||||
}
|
||||
|
||||
const ConditionList: React.FC<ConditionListProps> = ({
|
||||
conditions,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const formatConditionValue = (condition: FilterCondition) => {
|
||||
switch (condition.type) {
|
||||
case "range":
|
||||
return `${condition.value.min || 0}-${condition.value.max || 0}岁`;
|
||||
case "select":
|
||||
return condition.value;
|
||||
default:
|
||||
return condition.value;
|
||||
}
|
||||
};
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const ConditionList: React.FC<ConditionListProps> = ({ conditions, onRemove }) => {
|
||||
if (conditions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>自定义条件</div>
|
||||
<div className={styles.conditionList}>
|
||||
{conditions.map(condition => (
|
||||
<div key={condition.id} className={styles.conditionItem}>
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={condition.id || condition.field || index} className={styles.conditionItem}>
|
||||
<div className={styles.conditionContent}>
|
||||
<span className={styles.conditionLabel}>{condition.label}:</span>
|
||||
<span className={styles.conditionValue}>
|
||||
{formatConditionValue(condition)}
|
||||
<span className={styles.label}>{condition.label}</span>
|
||||
<span className={styles.value}>
|
||||
{condition.displayValue || condition.value}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => onRemove(condition.id)}
|
||||
className={styles.removeBtn}
|
||||
onClick={() => onRemove(condition)}
|
||||
>
|
||||
<DeleteOutline />
|
||||
<CloseCircleOutline />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,79 +2,247 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f4f7f9;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #4b5563;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #007aff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagItem {
|
||||
padding: 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #1677ff;
|
||||
background-color: #e6f7ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.rangeInputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rangeSeparator {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
.tagItem {
|
||||
padding: 14px;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f3f4f6;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #007aff;
|
||||
background-color: #f0f7ff;
|
||||
color: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px 24px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.2);
|
||||
}
|
||||
|
||||
// 钻取式地区选择器
|
||||
.regionDrillDown {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.regionDrillHeader {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.regionDrillTitle {
|
||||
font-size: 17px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
color: #007aff;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
--font-size: 15px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
color: #999;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.regionDrillContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.flatList {
|
||||
--adm-font-size-main: 16px;
|
||||
|
||||
:global(.adm-list-item-content) {
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
:global(.adm-list-item) {
|
||||
--active-background-color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.allProvinceOption {
|
||||
:global(.adm-list-item-content-main) {
|
||||
color: #007aff;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.listDivider {
|
||||
padding: 10px 20px;
|
||||
background: #f4f7f9;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// 统一输入控件样式
|
||||
.inputWrapper {
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
background: #fff;
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(.adm-input) {
|
||||
--font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 触发器样式
|
||||
.nativeTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f1f5f9;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.triggerValueActive {
|
||||
flex: 1;
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.triggerPlaceholder {
|
||||
flex: 1;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.triggerArrow {
|
||||
color: #94a3b8;
|
||||
font-size: 20px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
// 强制覆盖边框
|
||||
:global(.adm-input-element), :global(.adm-text-area-element) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.searchTip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
:global(.adm-selector) {
|
||||
--border-radius: 12px;
|
||||
--padding: 12px 16px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,144 @@
|
||||
import React, { useState } from "react";
|
||||
import { Popup, Form, Input, Selector, Button } from "antd-mobile";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, Button, Input, Selector, List } from "antd-mobile";
|
||||
import styles from "./CustomConditionModal.module.scss";
|
||||
import { getIndustryOptions, FilterField } from "../api";
|
||||
|
||||
// 省市数据
|
||||
const areaData = [
|
||||
{ label: '北京', value: '110000', children: [{ label: '北京市', value: '110100' }] },
|
||||
{ label: '天津', value: '120000', children: [{ label: '天津市', value: '120100' }] },
|
||||
{ label: '河北', value: '130000', children: [
|
||||
{ label: '石家庄市', value: '130100' }, { label: '唐山市', value: '130200' },
|
||||
{ label: '秦皇岛市', value: '130300' }, { label: '邯郸市', value: '130400' },
|
||||
{ label: '邢台市', value: '130500' }, { label: '保定市', value: '130600' },
|
||||
{ label: '张家口市', value: '130700' }, { label: '承德市', value: '130800' },
|
||||
{ label: '沧州市', value: '130900' }, { label: '廊坊市', value: '131000' },
|
||||
]},
|
||||
{ label: '山西', value: '140000', children: [
|
||||
{ label: '太原市', value: '140100' }, { label: '大同市', value: '140200' },
|
||||
{ label: '阳泉市', value: '140300' }, { label: '长治市', value: '140400' },
|
||||
{ label: '晋城市', value: '140500' }, { label: '朔州市', value: '140600' },
|
||||
]},
|
||||
{ label: '内蒙古', value: '150000', children: [
|
||||
{ label: '呼和浩特市', value: '150100' }, { label: '包头市', value: '150200' },
|
||||
{ label: '乌海市', value: '150300' }, { label: '赤峰市', value: '150400' },
|
||||
]},
|
||||
{ label: '辽宁', value: '210000', children: [
|
||||
{ label: '沈阳市', value: '210100' }, { label: '大连市', value: '210200' },
|
||||
{ label: '鞍山市', value: '210300' }, { label: '抚顺市', value: '210400' },
|
||||
]},
|
||||
{ label: '吉林', value: '220000', children: [
|
||||
{ label: '长春市', value: '220100' }, { label: '吉林市', value: '220200' },
|
||||
{ label: '四平市', value: '220300' }, { label: '辽源市', value: '220400' },
|
||||
]},
|
||||
{ label: '黑龙江', value: '230000', children: [
|
||||
{ label: '哈尔滨市', value: '230100' }, { label: '齐齐哈尔市', value: '230200' },
|
||||
{ label: '鸡西市', value: '230300' }, { label: '鹤岗市', value: '230400' },
|
||||
]},
|
||||
{ label: '上海', value: '310000', children: [{ label: '上海市', value: '310100' }] },
|
||||
{ label: '江苏', value: '320000', children: [
|
||||
{ label: '南京市', value: '320100' }, { label: '无锡市', value: '320200' },
|
||||
{ label: '徐州市', value: '320300' }, { label: '常州市', value: '320400' },
|
||||
{ label: '苏州市', value: '320500' }, { label: '南通市', value: '320600' },
|
||||
]},
|
||||
{ label: '浙江', value: '330000', children: [
|
||||
{ label: '杭州市', value: '330100' }, { label: '宁波市', value: '330200' },
|
||||
{ label: '温州市', value: '330300' }, { label: '嘉兴市', value: '330400' },
|
||||
{ label: '湖州市', value: '330500' }, { label: '绍兴市', value: '330600' },
|
||||
]},
|
||||
{ label: '安徽', value: '340000', children: [
|
||||
{ label: '合肥市', value: '340100' }, { label: '芜湖市', value: '340200' },
|
||||
{ label: '蚌埠市', value: '340300' }, { label: '淮南市', value: '340400' },
|
||||
]},
|
||||
{ label: '福建', value: '350000', children: [
|
||||
{ label: '福州市', value: '350100' }, { label: '厦门市', value: '350200' },
|
||||
{ label: '莆田市', value: '350300' }, { label: '三明市', value: '350400' },
|
||||
{ label: '泉州市', value: '350500' }, { label: '漳州市', value: '350600' },
|
||||
]},
|
||||
{ label: '江西', value: '360000', children: [
|
||||
{ label: '南昌市', value: '360100' }, { label: '景德镇市', value: '360200' },
|
||||
{ label: '萍乡市', value: '360300' }, { label: '九江市', value: '360400' },
|
||||
]},
|
||||
{ label: '山东', value: '370000', children: [
|
||||
{ label: '济南市', value: '370100' }, { label: '青岛市', value: '370200' },
|
||||
{ label: '淄博市', value: '370300' }, { label: '枣庄市', value: '370400' },
|
||||
{ label: '东营市', value: '370500' }, { label: '烟台市', value: '370600' },
|
||||
]},
|
||||
{ label: '河南', value: '410000', children: [
|
||||
{ label: '郑州市', value: '410100' }, { label: '开封市', value: '410200' },
|
||||
{ label: '洛阳市', value: '410300' }, { label: '平顶山市', value: '410400' },
|
||||
{ label: '安阳市', value: '410500' }, { label: '鹤壁市', value: '410600' },
|
||||
{ label: '新乡市', value: '410700' }, { label: '焦作市', value: '410800' },
|
||||
{ label: '濮阳市', value: '410900' }, { label: '许昌市', value: '411000' },
|
||||
{ label: '漯河市', value: '411100' }, { label: '三门峡市', value: '411200' },
|
||||
{ label: '南阳市', value: '411300' }, { label: '商丘市', value: '411400' },
|
||||
{ label: '信阳市', value: '411500' }, { label: '周口市', value: '411600' },
|
||||
{ label: '驻马店市', value: '411700' },
|
||||
]},
|
||||
{ label: '湖北', value: '420000', children: [
|
||||
{ label: '武汉市', value: '420100' }, { label: '黄石市', value: '420200' },
|
||||
{ label: '十堰市', value: '420300' }, { label: '宜昌市', value: '420500' },
|
||||
]},
|
||||
{ label: '湖南', value: '430000', children: [
|
||||
{ label: '长沙市', value: '430100' }, { label: '株洲市', value: '430200' },
|
||||
{ label: '湘潭市', value: '430300' }, { label: '衡阳市', value: '430400' },
|
||||
]},
|
||||
{ label: '广东', value: '440000', children: [
|
||||
{ label: '广州市', value: '440100' }, { label: '韶关市', value: '440200' },
|
||||
{ label: '深圳市', value: '440300' }, { label: '珠海市', value: '440400' },
|
||||
{ label: '汕头市', value: '440500' }, { label: '佛山市', value: '440600' },
|
||||
{ label: '江门市', value: '440700' }, { label: '湛江市', value: '440800' },
|
||||
{ label: '茂名市', value: '440900' }, { label: '肇庆市', value: '441200' },
|
||||
{ label: '惠州市', value: '441300' }, { label: '梅州市', value: '441400' },
|
||||
{ label: '汕尾市', value: '441500' }, { label: '河源市', value: '441600' },
|
||||
{ label: '阳江市', value: '441700' }, { label: '清远市', value: '441800' },
|
||||
{ label: '东莞市', value: '441900' }, { label: '中山市', value: '442000' },
|
||||
]},
|
||||
{ label: '广西', value: '450000', children: [
|
||||
{ label: '南宁市', value: '450100' }, { label: '柳州市', value: '450200' },
|
||||
{ label: '桂林市', value: '450300' }, { label: '梧州市', value: '450400' },
|
||||
]},
|
||||
{ label: '海南', value: '460000', children: [
|
||||
{ label: '海口市', value: '460100' }, { label: '三亚市', value: '460200' },
|
||||
]},
|
||||
{ label: '重庆', value: '500000', children: [{ label: '重庆市', value: '500100' }] },
|
||||
{ label: '四川', value: '510000', children: [
|
||||
{ label: '成都市', value: '510100' }, { label: '自贡市', value: '510300' },
|
||||
{ label: '攀枝花市', value: '510400' }, { label: '泸州市', value: '510500' },
|
||||
{ label: '德阳市', value: '510600' }, { label: '绵阳市', value: '510700' },
|
||||
]},
|
||||
{ label: '贵州', value: '520000', children: [
|
||||
{ label: '贵阳市', value: '520100' }, { label: '六盘水市', value: '520200' },
|
||||
{ label: '遵义市', value: '520300' }, { label: '安顺市', value: '520400' },
|
||||
]},
|
||||
{ label: '云南', value: '530000', children: [
|
||||
{ label: '昆明市', value: '530100' }, { label: '曲靖市', value: '530300' },
|
||||
{ label: '玉溪市', value: '530400' }, { label: '保山市', value: '530500' },
|
||||
]},
|
||||
{ label: '西藏', value: '540000', children: [
|
||||
{ label: '拉萨市', value: '540100' }, { label: '日喀则市', value: '540200' },
|
||||
]},
|
||||
{ label: '陕西', value: '610000', children: [
|
||||
{ label: '西安市', value: '610100' }, { label: '铜川市', value: '610200' },
|
||||
{ label: '宝鸡市', value: '610300' }, { label: '咸阳市', value: '610400' },
|
||||
]},
|
||||
{ label: '甘肃', value: '620000', children: [
|
||||
{ label: '兰州市', value: '620100' }, { label: '嘉峪关市', value: '620200' },
|
||||
{ label: '金昌市', value: '620300' }, { label: '白银市', value: '620400' },
|
||||
]},
|
||||
{ label: '青海', value: '630000', children: [
|
||||
{ label: '西宁市', value: '630100' }, { label: '海东市', value: '630200' },
|
||||
]},
|
||||
{ label: '宁夏', value: '640000', children: [
|
||||
{ label: '银川市', value: '640100' }, { label: '石嘴山市', value: '640200' },
|
||||
]},
|
||||
{ label: '新疆', value: '650000', children: [
|
||||
{ label: '乌鲁木齐市', value: '650100' }, { label: '克拉玛依市', value: '650200' },
|
||||
]},
|
||||
{ label: '台湾', value: '710000', children: [{ label: '台北市', value: '710100' }] },
|
||||
{ label: '香港', value: '810000', children: [{ label: '香港特别行政区', value: '810100' }] },
|
||||
{ label: '澳门', value: '820000', children: [{ label: '澳门特别行政区', value: '820100' }] },
|
||||
];
|
||||
|
||||
interface CustomConditionModalProps {
|
||||
visible: boolean;
|
||||
@@ -8,111 +146,31 @@ interface CustomConditionModalProps {
|
||||
onAdd: (condition: any) => void;
|
||||
}
|
||||
|
||||
// 模拟标签数据
|
||||
const mockTags = [
|
||||
{ id: "age", name: "年龄层", type: "range", options: [] },
|
||||
{
|
||||
id: "consumption",
|
||||
name: "消费能力",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "高", value: "high" },
|
||||
{ label: "中", value: "medium" },
|
||||
{ label: "低", value: "low" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gender",
|
||||
name: "性别",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "男", value: "male" },
|
||||
{ label: "女", value: "female" },
|
||||
{ label: "未知", value: "unknown" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
name: "所在地区",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "厦门", value: "xiamen" },
|
||||
{ label: "泉州", value: "quanzhou" },
|
||||
{ label: "福州", value: "fuzhou" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
name: "客户来源",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "抖音", value: "douyin" },
|
||||
{ label: "门店扫码", value: "store" },
|
||||
{ label: "朋友推荐", value: "referral" },
|
||||
{ label: "广告投放", value: "ad" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "frequency",
|
||||
name: "消费频率",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "高频(>3次/月)", value: "high" },
|
||||
{ label: "中频", value: "medium" },
|
||||
{ label: "低频", value: "low" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sensitivity",
|
||||
name: "优惠敏感度",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "高", value: "high" },
|
||||
{ label: "中", value: "medium" },
|
||||
{ label: "低", value: "low" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "category",
|
||||
name: "品类偏好",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "护肤", value: "skincare" },
|
||||
{ label: "茶饮", value: "tea" },
|
||||
{ label: "宠物", value: "pet" },
|
||||
{ label: "课程", value: "course" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "repurchase",
|
||||
name: "复购行为",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "有", value: "yes" },
|
||||
{ label: "无", value: "no" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "satisfaction",
|
||||
name: "售后满意度",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "好评", value: "good" },
|
||||
{ label: "一般", value: "average" },
|
||||
{ label: "差评", value: "bad" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onAdd,
|
||||
}) => {
|
||||
const [selectedTag, setSelectedTag] = useState<any>(null);
|
||||
const [fields, setFields] = useState<FilterField[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<FilterField | null>(null);
|
||||
const [conditionValue, setConditionValue] = useState<any>(null);
|
||||
|
||||
const handleTagSelect = (tag: any) => {
|
||||
// 地区选择器状态
|
||||
const [regionVisible, setRegionVisible] = useState(false);
|
||||
const [regionStep, setRegionStep] = useState<'province' | 'city'>('province');
|
||||
const [tempProvince, setTempProvince] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setLoading(true);
|
||||
getIndustryOptions()
|
||||
.then(res => setFields(res || []))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleTagSelect = (tag: FilterField) => {
|
||||
setSelectedTag(tag);
|
||||
setConditionValue(null);
|
||||
};
|
||||
@@ -122,13 +180,29 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedTag || !conditionValue) return;
|
||||
if (!selectedTag || (conditionValue === null || conditionValue === undefined || conditionValue === '')) return;
|
||||
|
||||
let displayValue = conditionValue;
|
||||
let operator = '=';
|
||||
|
||||
if (selectedTag.type === 'select' && selectedTag.options) {
|
||||
const option = selectedTag.options.find((opt: any) => opt.value === conditionValue);
|
||||
displayValue = option ? option.label : conditionValue;
|
||||
} else if (selectedTag.type === 'number') {
|
||||
operator = '>=';
|
||||
} else if (selectedTag.type === 'friend_search') {
|
||||
operator = 'like';
|
||||
displayValue = `包含"${conditionValue}"`;
|
||||
}
|
||||
|
||||
const condition = {
|
||||
id: `${selectedTag.id}_${Date.now()}`,
|
||||
type: selectedTag.type,
|
||||
label: selectedTag.name,
|
||||
type: "field",
|
||||
field: selectedTag.field,
|
||||
operator: operator,
|
||||
value: conditionValue,
|
||||
displayValue: displayValue,
|
||||
label: selectedTag.label,
|
||||
fieldType: selectedTag.type,
|
||||
};
|
||||
|
||||
onAdd(condition);
|
||||
@@ -137,48 +211,161 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
setConditionValue(null);
|
||||
};
|
||||
|
||||
const renderRegionPicker = () => {
|
||||
return (
|
||||
<Popup
|
||||
visible={regionVisible}
|
||||
onMaskClick={() => setRegionVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: '70vh', borderTopLeftRadius: '20px', borderTopRightRadius: '20px' }}
|
||||
>
|
||||
<div className={styles.regionDrillDown}>
|
||||
<div className={styles.regionDrillHeader}>
|
||||
{regionStep === 'city' ? (
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
onClick={() => setRegionStep('province')}
|
||||
className={styles.backBtn}
|
||||
>
|
||||
← 返回省份
|
||||
</Button>
|
||||
) : (
|
||||
<span className={styles.regionDrillTitle}>选择地区</span>
|
||||
)}
|
||||
<Button fill="none" size="small" onClick={() => setRegionVisible(false)} className={styles.closeBtn}>取消</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.regionDrillContent}>
|
||||
{regionStep === 'province' ? (
|
||||
<List className={styles.flatList}>
|
||||
{areaData.map(item => (
|
||||
<List.Item
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
const provinceName = item.label.replace(/省$/, '');
|
||||
setTempProvince({ ...item, label: provinceName });
|
||||
setRegionStep('city');
|
||||
}}
|
||||
arrow={true}
|
||||
>
|
||||
{item.label}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<List className={styles.flatList}>
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
handleValueChange(tempProvince.label);
|
||||
setRegionVisible(false);
|
||||
}}
|
||||
className={styles.allProvinceOption}
|
||||
arrow={false}
|
||||
>
|
||||
<span className={styles.primaryText}>选择全省:{tempProvince.label}</span>
|
||||
</List.Item>
|
||||
|
||||
<div className={styles.listDivider}>选择城市</div>
|
||||
|
||||
{tempProvince.children?.map((item: any) => (
|
||||
<List.Item
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
const cityName = item.label.replace(/市$/, '');
|
||||
handleValueChange(`${tempProvince.label} ${cityName}`);
|
||||
setRegionVisible(false);
|
||||
}}
|
||||
arrow={false}
|
||||
>
|
||||
{item.label}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
const renderValueInput = () => {
|
||||
if (!selectedTag) return null;
|
||||
|
||||
switch (selectedTag.type) {
|
||||
case "range":
|
||||
case "friend_search":
|
||||
return (
|
||||
<div className={styles.rangeInputs}>
|
||||
<Input
|
||||
placeholder="最小年龄"
|
||||
type="number"
|
||||
onChange={value =>
|
||||
setConditionValue(prev => ({ ...prev, min: value }))
|
||||
}
|
||||
/>
|
||||
<span className={styles.rangeSeparator}>-</span>
|
||||
<Input
|
||||
placeholder="最大年龄"
|
||||
type="number"
|
||||
onChange={value =>
|
||||
setConditionValue(prev => ({ ...prev, max: value }))
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<div className={styles.inputWrapper}>
|
||||
<Input
|
||||
placeholder={selectedTag.placeholder || "输入昵称、微信号、手机号搜索"}
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchTip}>
|
||||
输入关键词后,将筛选出匹配的好友
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "province":
|
||||
return (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<div
|
||||
className={styles.nativeTrigger}
|
||||
onClick={() => {
|
||||
setRegionVisible(true);
|
||||
setRegionStep('province');
|
||||
}}
|
||||
>
|
||||
<span className={styles.triggerLabel}>地区:</span>
|
||||
<span className={conditionValue ? styles.triggerValueActive : styles.triggerPlaceholder}>
|
||||
{conditionValue || '请选择'}
|
||||
</span>
|
||||
<span className={styles.triggerArrow}>›</span>
|
||||
</div>
|
||||
{renderRegionPicker()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Selector
|
||||
options={selectedTag.options}
|
||||
value={conditionValue ? [conditionValue] : []}
|
||||
onChange={value => handleValueChange(value[0])}
|
||||
multiple={false}
|
||||
columns={2}
|
||||
options={selectedTag.options || []}
|
||||
value={[conditionValue]}
|
||||
onChange={v => handleValueChange(v[0])}
|
||||
style={{
|
||||
'--border-radius': '12px',
|
||||
'--padding': '12px 16px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<div className={styles.inputWrapper}>
|
||||
<Input
|
||||
placeholder={`请输入${selectedTag.label}数值`}
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "input":
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
placeholder="请输入值"
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
<div className={styles.inputWrapper}>
|
||||
<Input
|
||||
placeholder={`请输入${selectedTag.label}`}
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -188,37 +375,43 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "70vh" }}
|
||||
bodyStyle={{ height: "80vh", borderTopLeftRadius: '24px', borderTopRightRadius: '24px' }}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>添加自定义条件</div>
|
||||
<Button size="small" fill="none" onClick={onClose}>
|
||||
<div className={styles.title}>添加筛选条件</div>
|
||||
<Button size="small" fill="none" onClick={onClose} style={{ color: '#666' }}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>选择标签</div>
|
||||
<div className={styles.sectionTitle}>选择维度</div>
|
||||
<div className={styles.tagList}>
|
||||
{mockTags.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`${styles.tagItem} ${
|
||||
selectedTag?.id === tag.id ? styles.selected : ""
|
||||
}`}
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
>
|
||||
{tag.name}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px", color: "#9ca3af" }}>
|
||||
加载中...
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
fields.map(field => (
|
||||
<div
|
||||
key={field.field}
|
||||
className={`${styles.tagItem} ${
|
||||
selectedTag?.field === field.field ? styles.selected : ""
|
||||
}`}
|
||||
onClick={() => handleTagSelect(field)}
|
||||
>
|
||||
{field.label}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTag && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>设置条件</div>
|
||||
<div className={styles.sectionTitle}>设定条件</div>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
)}
|
||||
@@ -228,10 +421,11 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
<Button
|
||||
color="primary"
|
||||
block
|
||||
disabled={!selectedTag || !conditionValue}
|
||||
disabled={!selectedTag || (conditionValue === null || conditionValue === undefined || conditionValue === '')}
|
||||
onClick={handleSubmit}
|
||||
className={styles.submitBtn}
|
||||
>
|
||||
添加条件
|
||||
确认添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
|
||||
:global(.adm-search-bar) {
|
||||
--background: #fff;
|
||||
--border-radius: 12px;
|
||||
--height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedBar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #f0f7ff;
|
||||
border-bottom: 1px solid #e0efff;
|
||||
}
|
||||
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #007aff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.totalCount {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
gap: 12px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.friendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #f0f7ff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
--size: 44px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.friendInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.friendName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wechatId {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Popup, Button, SearchBar, Checkbox, Avatar, SpinLoading, Empty } from "antd-mobile";
|
||||
import styles from "./FriendSearchModal.module.scss";
|
||||
import { getUsersByFilter } from "../api";
|
||||
|
||||
interface Friend {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface FriendSearchModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (friends: Friend[]) => void;
|
||||
existingConditions: any[]; // 已有的筛选条件
|
||||
selectedFriends?: Friend[]; // 已选中的好友
|
||||
}
|
||||
|
||||
const FriendSearchModal: React.FC<FriendSearchModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
existingConditions,
|
||||
selectedFriends = [],
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [friends, setFriends] = useState<Friend[]>([]);
|
||||
const [selected, setSelected] = useState<Friend[]>(selectedFriends);
|
||||
const [total, setTotal] = useState(0);
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 初始化已选中的好友
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelected(selectedFriends);
|
||||
// 自动加载一批数据
|
||||
searchFriends("");
|
||||
}
|
||||
}, [visible, selectedFriends]);
|
||||
|
||||
// 搜索好友
|
||||
const searchFriends = useCallback(async (searchKeyword: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 构造 ruleConfig,包含已有条件
|
||||
const conditions = existingConditions.filter(c => c.field !== 'friendIds'); // 排除已有的好友ID条件
|
||||
|
||||
const ruleConfig: any = {
|
||||
logic: "AND",
|
||||
conditions: conditions,
|
||||
};
|
||||
|
||||
// 添加搜索关键词
|
||||
if (searchKeyword) {
|
||||
ruleConfig.keyword = searchKeyword;
|
||||
}
|
||||
|
||||
const result = await getUsersByFilter({
|
||||
ruleConfig,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
// 转换数据格式
|
||||
const friendList: Friend[] = result.list.map((user: any) => ({
|
||||
id: String(user.id),
|
||||
name: user.nickname || user.wechatId || user.identifier,
|
||||
avatar: user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`,
|
||||
wechatId: user.wechatId || user.wechatAlias || "",
|
||||
tags: Array.isArray(user.tags)
|
||||
? user.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.tagName || '').slice(0, 2)
|
||||
: [],
|
||||
}));
|
||||
|
||||
setFriends(friendList);
|
||||
setTotal(result.total);
|
||||
} catch (error) {
|
||||
console.error("搜索好友失败:", error);
|
||||
setFriends([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [existingConditions]);
|
||||
|
||||
// 搜索防抖
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value);
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
searchFriends(value);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 切换选中状态
|
||||
const toggleSelect = (friend: Friend) => {
|
||||
setSelected(prev => {
|
||||
const exists = prev.find(f => f.id === friend.id);
|
||||
if (exists) {
|
||||
return prev.filter(f => f.id !== friend.id);
|
||||
} else {
|
||||
return [...prev, friend];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 全选当前页
|
||||
const selectAll = () => {
|
||||
const newSelected = [...selected];
|
||||
friends.forEach(friend => {
|
||||
if (!newSelected.find(f => f.id === friend.id)) {
|
||||
newSelected.push(friend);
|
||||
}
|
||||
});
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
// 取消全选
|
||||
const deselectAll = () => {
|
||||
const friendIds = friends.map(f => f.id);
|
||||
setSelected(prev => prev.filter(f => !friendIds.includes(f.id)));
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const allCurrentPageSelected = friends.length > 0 && friends.every(f => selected.find(s => s.id === f.id));
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "85vh", borderTopLeftRadius: '24px', borderTopRightRadius: '24px' }}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>选择好友</div>
|
||||
<Button size="small" fill="none" onClick={onClose} style={{ color: '#666' }}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.searchWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索昵称、微信号、手机号..."
|
||||
value={keyword}
|
||||
onChange={handleSearch}
|
||||
onClear={() => {
|
||||
setKeyword("");
|
||||
searchFriends("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className={styles.selectedBar}>
|
||||
<span className={styles.selectedCount}>已选 {selected.length} 人</span>
|
||||
<Button size="mini" fill="none" onClick={() => setSelected([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.listHeader}>
|
||||
<Checkbox
|
||||
checked={allCurrentPageSelected}
|
||||
onChange={checked => checked ? selectAll() : deselectAll()}
|
||||
>
|
||||
全选当前页
|
||||
</Checkbox>
|
||||
<span className={styles.totalCount}>共 {total} 人</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.friendList}>
|
||||
{loading ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<SpinLoading style={{ "--size": "32px" }} />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
) : friends.length === 0 ? (
|
||||
<Empty description={keyword ? "未找到匹配的好友" : "暂无数据"} />
|
||||
) : (
|
||||
friends.map(friend => {
|
||||
const isSelected = !!selected.find(f => f.id === friend.id);
|
||||
return (
|
||||
<div
|
||||
key={friend.id}
|
||||
className={`${styles.friendItem} ${isSelected ? styles.selected : ''}`}
|
||||
onClick={() => toggleSelect(friend)}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<Avatar src={friend.avatar} className={styles.avatar} />
|
||||
<div className={styles.friendInfo}>
|
||||
<div className={styles.friendName}>{friend.name}</div>
|
||||
{friend.wechatId && (
|
||||
<div className={styles.wechatId}>{friend.wechatId}</div>
|
||||
)}
|
||||
{friend.tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{friend.tags.map((tag, idx) => (
|
||||
<span key={idx} className={styles.tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
color="primary"
|
||||
block
|
||||
disabled={selected.length === 0}
|
||||
onClick={handleConfirm}
|
||||
className={styles.confirmBtn}
|
||||
>
|
||||
确认选择 ({selected.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default FriendSearchModal;
|
||||
@@ -1,49 +1,60 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.userCount {
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
color: #007aff;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.batchActions {
|
||||
.searchWrapper {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:global(.adm-search-bar) {
|
||||
--background: #f5f5f5;
|
||||
--border-radius: 12px;
|
||||
--height: 44px;
|
||||
--padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
gap: 12px;
|
||||
color: #8e8e93;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.batchBar {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.selectAllCheckbox {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.removeSelectedBtn {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
height: 28px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.userList {
|
||||
@@ -55,18 +66,24 @@
|
||||
.userItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f9fafb;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.userCheckbox {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
.avatar {
|
||||
--size: 48px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -75,52 +92,80 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
.nameRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.userId {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
.userName {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.userTags {
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tagsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #e6f7ff;
|
||||
color: #1677ff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
.tagItem {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: #f0f7ff;
|
||||
color: #007aff;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.userStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
.tagMore {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: #f5f5f5;
|
||||
color: #8e8e93;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
color: #ff4d4f;
|
||||
padding: 4px;
|
||||
color: #d1d5db;
|
||||
padding: 8px;
|
||||
--font-size: 22px;
|
||||
transition: color 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
&:active {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
color: #8e8e93;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, Avatar, Button, Checkbox, Empty } from "antd-mobile";
|
||||
import { DeleteOutline } from "antd-mobile-icons";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Avatar, Button, Checkbox, Empty, SearchBar, InfiniteScroll, SpinLoading } from "antd-mobile";
|
||||
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||
import styles from "./UserListPreview.module.scss";
|
||||
|
||||
interface User {
|
||||
@@ -15,139 +15,176 @@ interface User {
|
||||
|
||||
interface UserListPreviewProps {
|
||||
users: User[];
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
hasMore?: boolean;
|
||||
onRemoveUser: (userId: string) => void;
|
||||
onSearch?: (keyword: string) => void;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const UserListPreview: React.FC<UserListPreviewProps> = ({
|
||||
users,
|
||||
total = 0,
|
||||
loading = false,
|
||||
hasMore = false,
|
||||
onRemoveUser,
|
||||
onSearch,
|
||||
onLoadMore,
|
||||
}) => {
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUsers(users.map(user => user.id));
|
||||
} else {
|
||||
setSelectedUsers([]);
|
||||
// 搜索防抖
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
onSearch?.(value);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleSelectUser = (userId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUsers(prev => [...prev, userId]);
|
||||
} else {
|
||||
setSelectedUsers(prev => prev.filter(id => id !== userId));
|
||||
}
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getRfmBadge = (score: number) => {
|
||||
if (score >= 12) return { text: "CORE", color: "#ff3b30", bg: "#fff2f1" };
|
||||
if (score >= 8) return { text: "HIGH", color: "#ff9500", bg: "#fff9f2" };
|
||||
return { text: "USER", color: "#8e8e93", bg: "#f2f2f7" };
|
||||
};
|
||||
|
||||
const handleRemoveSelected = () => {
|
||||
selectedUsers.forEach(userId => onRemoveUser(userId));
|
||||
setSelectedUsers([]);
|
||||
};
|
||||
// 本地筛选(如果没有提供onSearch回调)
|
||||
const filteredUsers = onSearch
|
||||
? users
|
||||
: users.filter(user => {
|
||||
if (!searchKeyword) return true;
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
return (
|
||||
user.name?.toLowerCase().includes(keyword) ||
|
||||
user.tags?.some(tag => tag.toLowerCase().includes(keyword))
|
||||
);
|
||||
});
|
||||
|
||||
const getRfmLevel = (score: number) => {
|
||||
if (score >= 12) return { level: "高价值", color: "#ff4d4f" };
|
||||
if (score >= 8) return { level: "中等价值", color: "#faad14" };
|
||||
if (score >= 4) return { level: "低价值", color: "#52c41a" };
|
||||
return { level: "潜在客户", color: "#bfbfbf" };
|
||||
};
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.card}>
|
||||
<Empty description="暂无用户数据" />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const displayTotal = total || filteredUsers.length;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>用户列表预览</div>
|
||||
<div className={styles.userCount}>共 {users.length} 个用户</div>
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>符合条件的用户</div>
|
||||
<div className={styles.userCount}>{displayTotal}人</div>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<div className={styles.batchActions}>
|
||||
{/* 搜索框 */}
|
||||
<div className={styles.searchWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索昵称、微信号、标签..."
|
||||
value={searchKeyword}
|
||||
onChange={handleSearchChange}
|
||||
onClear={() => {
|
||||
setSearchKeyword("");
|
||||
onSearch?.("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && users.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<SpinLoading style={{ "--size": "32px" }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div style={{ paddingTop: 40 }}>
|
||||
<Empty description={searchKeyword ? "未找到匹配用户" : "暂无用户数据"} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.batchBar}>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedUsers.length === users.length && users.length > 0
|
||||
}
|
||||
onChange={handleSelectAll}
|
||||
className={styles.selectAllCheckbox}
|
||||
checked={selectedUsers.length === filteredUsers.length && filteredUsers.length > 0}
|
||||
onChange={checked => setSelectedUsers(checked ? filteredUsers.map(u => u.id) : [])}
|
||||
style={{ '--font-size': '14px' }}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
{selectedUsers.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
fill="outline"
|
||||
onClick={handleRemoveSelected}
|
||||
className={styles.removeSelectedBtn}
|
||||
>
|
||||
移除选中 ({selectedUsers.length})
|
||||
<Button size="mini" color="danger" fill="none" onClick={() => {
|
||||
selectedUsers.forEach(onRemoveUser);
|
||||
setSelectedUsers([]);
|
||||
}}>
|
||||
批量删除 ({selectedUsers.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.userList}>
|
||||
{users.map(user => {
|
||||
const rfmInfo = getRfmLevel(user.rfmScore);
|
||||
|
||||
return (
|
||||
<div key={user.id} className={styles.userItem}>
|
||||
<Checkbox
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onChange={checked => handleSelectUser(user.id, checked)}
|
||||
className={styles.userCheckbox}
|
||||
/>
|
||||
|
||||
<Avatar src={user.avatar} className={styles.userAvatar} />
|
||||
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.userName}>{user.name}</div>
|
||||
<div className={styles.userId}>ID: {user.id}</div>
|
||||
<div className={styles.userTags}>
|
||||
{user.tags.map((tag, index) => (
|
||||
<span key={index} className={styles.tag}>
|
||||
{tag}
|
||||
<div className={styles.userList}>
|
||||
{filteredUsers.map(user => {
|
||||
const badge = getRfmBadge(user.rfmScore);
|
||||
return (
|
||||
<div key={user.id} className={styles.userItem}>
|
||||
<Checkbox
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onChange={checked => setSelectedUsers(prev => checked ? [...prev, user.id] : prev.filter(id => id !== user.id))}
|
||||
/>
|
||||
<Avatar src={user.avatar} className={styles.avatar} />
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.nameRow}>
|
||||
<span className={styles.userName}>{user.name}</span>
|
||||
<span className={styles.badge} style={{ color: badge.color, background: badge.bg }}>
|
||||
{badge.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.userStats}>
|
||||
<span className={styles.statItem}>
|
||||
RFM:{" "}
|
||||
<span style={{ color: rfmInfo.color }}>
|
||||
{rfmInfo.level}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.statItem}>
|
||||
活跃: {user.lastActive}
|
||||
</span>
|
||||
<span className={styles.statItem}>
|
||||
消费: ¥{user.consumption}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metaRow}>
|
||||
消费 ¥{user.consumption} · 活跃 {user.lastActive}
|
||||
</div>
|
||||
{user.tags && user.tags.length > 0 && (
|
||||
<div className={styles.tagsRow}>
|
||||
{user.tags.slice(0, 3).map((tag, idx) => (
|
||||
<span key={idx} className={styles.tagItem}>{tag}</span>
|
||||
))}
|
||||
{user.tags.length > 3 && (
|
||||
<span className={styles.tagMore}>+{user.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
fill="none"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => onRemoveUser(user.id)}
|
||||
>
|
||||
<CloseCircleOutline />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => onRemoveUser(user.id)}
|
||||
className={styles.removeBtn}
|
||||
>
|
||||
<DeleteOutline />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
{/* 无限滚动加载更多 */}
|
||||
{onLoadMore && (
|
||||
<InfiniteScroll loadMore={onLoadMore} hasMore={hasMore}>
|
||||
{hasMore ? (
|
||||
<div className={styles.loadingMore}>
|
||||
<SpinLoading style={{ "--size": "24px" }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>没有更多了</span>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
.tabsContainer {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
:global(.adm-tabs-header) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.adm-tabs-tab) {
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
:global(.adm-tabs-tab-active) {
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mainScroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 200px);
|
||||
padding: 8px 0 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prevButton {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #4b5563;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.nextButton {
|
||||
flex: 1;
|
||||
.nextButton, .submitButton {
|
||||
flex: 2;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
background: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.2);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
flex: 1;
|
||||
.stepHeader {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "antd-mobile";
|
||||
import { Button, Toast } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import BasicInfo from "./components/BasicInfo";
|
||||
@@ -7,10 +8,16 @@ import AudienceFilter from "./components/AudienceFilter";
|
||||
import UserListPreview from "./components/UserListPreview";
|
||||
import styles from "./index.module.scss";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import { createTrafficPackage, getUsersByFilter } from "./api";
|
||||
|
||||
const CreateTrafficPackage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1); // 1 基础信息 2 人群筛选 3 用户列表
|
||||
const [submitting, setSubmitting] = useState(false); // 添加提交状态
|
||||
const [loading, setLoading] = useState(false); // 加载用户列表状态
|
||||
const [searchKeyword, setSearchKeyword] = useState(""); // 搜索关键词
|
||||
const [currentPage, setCurrentPage] = useState(1); // 当前页
|
||||
const [hasMore, setHasMore] = useState(false); // 是否还有更多数据
|
||||
const [formData, setFormData] = useState({
|
||||
// 基本信息
|
||||
name: "",
|
||||
@@ -20,6 +27,7 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
filterConditions: [],
|
||||
// 用户列表
|
||||
filteredUsers: [],
|
||||
filteredUsersTotal: 0,
|
||||
});
|
||||
|
||||
const steps = [
|
||||
@@ -40,59 +48,6 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
setFormData(prev => ({ ...prev, filteredUsers: users }));
|
||||
};
|
||||
|
||||
// 初始化模拟数据
|
||||
React.useEffect(() => {
|
||||
if (currentStep === 3 && formData.filteredUsers.length === 0) {
|
||||
const mockUsers = [
|
||||
{
|
||||
id: "U00000001",
|
||||
name: "张三",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
|
||||
tags: ["高价值用户", "活跃用户"],
|
||||
rfmScore: 12,
|
||||
lastActive: "7天内",
|
||||
consumption: 2500,
|
||||
},
|
||||
{
|
||||
id: "U00000002",
|
||||
name: "李四",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
||||
tags: ["新用户", "价格敏感"],
|
||||
rfmScore: 6,
|
||||
lastActive: "3天内",
|
||||
consumption: 800,
|
||||
},
|
||||
{
|
||||
id: "U00000003",
|
||||
name: "王五",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=3",
|
||||
tags: ["复购率高", "高潜力"],
|
||||
rfmScore: 14,
|
||||
lastActive: "1天内",
|
||||
consumption: 3200,
|
||||
},
|
||||
{
|
||||
id: "U00000004",
|
||||
name: "赵六",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=4",
|
||||
tags: ["已沉睡", "流失风险"],
|
||||
rfmScore: 3,
|
||||
lastActive: "30天内",
|
||||
consumption: 200,
|
||||
},
|
||||
{
|
||||
id: "U00000005",
|
||||
name: "钱七",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=5",
|
||||
tags: ["高价值用户", "复购率高"],
|
||||
rfmScore: 15,
|
||||
lastActive: "2天内",
|
||||
consumption: 4500,
|
||||
},
|
||||
];
|
||||
setFormData(prev => ({ ...prev, filteredUsers: mockUsers }));
|
||||
}
|
||||
}, [currentStep, formData.filteredUsers.length]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 防止重复提交
|
||||
@@ -100,20 +55,41 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name) {
|
||||
Toast.show({ content: "请填写流量包名称", icon: "fail" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.filterConditions || formData.filterConditions.length === 0) {
|
||||
Toast.show({ content: "请设置筛选条件", icon: "fail" });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 提交逻辑
|
||||
console.log("提交数据:", formData);
|
||||
// 这里可以调用实际的 API
|
||||
// await createTrafficPackage(formData);
|
||||
// 构造ruleConfig
|
||||
const ruleConfig = {
|
||||
logic: "AND",
|
||||
conditions: formData.filterConditions,
|
||||
};
|
||||
|
||||
// 模拟 API 调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// 调用创建接口
|
||||
const result = await createTrafficPackage({
|
||||
groupName: formData.name,
|
||||
description: formData.description || undefined,
|
||||
ruleType: 1, // 动态规则
|
||||
ruleConfig: ruleConfig,
|
||||
});
|
||||
|
||||
// 提交成功后可以跳转或显示成功消息
|
||||
console.log("流量包创建成功");
|
||||
} catch (error) {
|
||||
Toast.show({ content: "创建成功", icon: "success" });
|
||||
|
||||
// 跳转回列表页
|
||||
setTimeout(() => {
|
||||
navigate("/mine/traffic-pool");
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
console.error("创建流量包失败:", error);
|
||||
Toast.show({ content: error?.message || "创建失败", icon: "fail" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -121,24 +97,87 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
|
||||
const canSubmit = formData.name && formData.filterConditions.length > 0;
|
||||
|
||||
// 模拟生成用户数据
|
||||
const generateMockUsers = (conditions: any[]) => {
|
||||
const mockUsers = [];
|
||||
const userCount = Math.floor(Math.random() * 1000) + 100; // 100-1100个用户
|
||||
// 转换用户数据格式
|
||||
const formatUserData = (user: any) => ({
|
||||
id: String(user.id),
|
||||
name: user.nickname || user.wechatId || user.identifier,
|
||||
avatar: user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`,
|
||||
tags: Array.isArray(user.tags) ? user.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.tagName || '') : [],
|
||||
rfmScore: user.rfmScore?.total || 0,
|
||||
lastActive: user.lastInteractTime ? `${Math.floor((Date.now() / 1000 - user.lastInteractTime) / 86400)}天前` : "从未",
|
||||
consumption: parseFloat(user.totalOrderAmount || "0"),
|
||||
});
|
||||
|
||||
for (let i = 1; i <= userCount; i++) {
|
||||
mockUsers.push({
|
||||
id: `U${String(i).padStart(8, "0")}`,
|
||||
name: `用户${i}`,
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
||||
tags: ["高价值用户", "活跃用户"],
|
||||
rfmScore: Math.floor(Math.random() * 15) + 1,
|
||||
lastActive: "7天内",
|
||||
consumption: Math.floor(Math.random() * 5000) + 100,
|
||||
});
|
||||
// 根据筛选条件获取真实用户数据
|
||||
const loadUsersByFilter = async (conditions: any[], keyword: string = "", page: number = 1, append: boolean = false) => {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mockUsers;
|
||||
setLoading(true);
|
||||
try {
|
||||
// 构造ruleConfig,如果有关键词搜索,添加额外条件
|
||||
let ruleConfig: any = {
|
||||
logic: "AND",
|
||||
conditions: [...conditions],
|
||||
};
|
||||
|
||||
// 如果有搜索关键词,添加搜索条件
|
||||
if (keyword) {
|
||||
ruleConfig.keyword = keyword;
|
||||
}
|
||||
|
||||
const result = await getUsersByFilter({
|
||||
ruleConfig,
|
||||
page,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
// 转换为组件需要的格式
|
||||
const users = result.list.map(formatUserData);
|
||||
|
||||
// 判断是否还有更多数据
|
||||
const totalLoaded = append ? formData.filteredUsers.length + users.length : users.length;
|
||||
setHasMore(totalLoaded < result.total);
|
||||
setCurrentPage(page);
|
||||
|
||||
if (append) {
|
||||
// 追加模式
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
filteredUsers: [...prev.filteredUsers, ...users],
|
||||
filteredUsersTotal: result.total,
|
||||
}));
|
||||
} else {
|
||||
// 替换模式
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
filteredUsers: users,
|
||||
filteredUsersTotal: result.total,
|
||||
}));
|
||||
}
|
||||
|
||||
return users;
|
||||
} catch (error: any) {
|
||||
console.error("获取用户列表失败:", error);
|
||||
Toast.show({ content: error?.message || "获取用户列表失败", icon: "fail" });
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索用户
|
||||
const handleSearchUsers = async (keyword: string) => {
|
||||
setSearchKeyword(keyword);
|
||||
setCurrentPage(1);
|
||||
await loadUsersByFilter(formData.filterConditions, keyword, 1, false);
|
||||
};
|
||||
|
||||
// 加载更多用户
|
||||
const handleLoadMoreUsers = async () => {
|
||||
if (loading || !hasMore) return;
|
||||
await loadUsersByFilter(formData.filterConditions, searchKeyword, currentPage + 1, true);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
@@ -158,19 +197,20 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
<Button
|
||||
color="primary"
|
||||
className={styles.nextButton}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
if (currentStep === 2) {
|
||||
// 在第二步时生成用户列表
|
||||
const mockUsers = generateMockUsers(
|
||||
// 在第二步时加载真实用户列表
|
||||
const users = await loadUsersByFilter(
|
||||
formData.filterConditions,
|
||||
);
|
||||
handleGenerateUsers(mockUsers);
|
||||
handleGenerateUsers(users);
|
||||
}
|
||||
setCurrentStep(s => Math.min(3, s + 1));
|
||||
}}
|
||||
disabled={submitting}
|
||||
disabled={submitting || loading}
|
||||
loading={loading}
|
||||
>
|
||||
下一步
|
||||
{currentStep === 2 && loading ? "加载中..." : "下一步"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -191,10 +231,10 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title="新建流量包" />
|
||||
<div className={styles.stepHeader}>
|
||||
<NavCommon title="新建流量池" />
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
@@ -213,14 +253,20 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
{currentStep === 3 && (
|
||||
<UserListPreview
|
||||
users={formData.filteredUsers}
|
||||
total={formData.filteredUsersTotal}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onRemoveUser={userId => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
filteredUsers: prev.filteredUsers.filter(
|
||||
(user: any) => user.id !== userId,
|
||||
),
|
||||
filteredUsersTotal: prev.filteredUsersTotal - 1,
|
||||
}));
|
||||
}}
|
||||
onSearch={handleSearchUsers}
|
||||
onLoadMore={handleLoadMoreUsers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
getGroups as getGroupsV2,
|
||||
deleteGroup as deleteGroupV2,
|
||||
type TrafficPoolGroup,
|
||||
} from "../api";
|
||||
|
||||
// 兼容旧版 Package 接口
|
||||
export interface Package {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
pic: string;
|
||||
type: number;
|
||||
type: number; // 0=自定义,1=系统
|
||||
createTime: string;
|
||||
num: number;
|
||||
R: number;
|
||||
@@ -13,19 +19,88 @@ export interface Package {
|
||||
M: number;
|
||||
RFM: number;
|
||||
}
|
||||
|
||||
export interface PackageList {
|
||||
list: Package[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池分组列表 (V2)
|
||||
* 转换为旧版 Package 格式以保持兼容
|
||||
*/
|
||||
export async function getPackage(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword: string;
|
||||
}): Promise<PackageList> {
|
||||
return request("/v1/traffic/pool/getPackage", params, "GET");
|
||||
try {
|
||||
const groups = await getGroupsV2();
|
||||
|
||||
// 转换为旧版格式
|
||||
let list: Package[] = groups.map((group: TrafficPoolGroup) => {
|
||||
// 处理 createTime:可能是时间戳(数字)或日期字符串
|
||||
let formattedTime = "";
|
||||
if (group.createTime) {
|
||||
if (typeof group.createTime === "number") {
|
||||
// Unix 时间戳(秒)
|
||||
formattedTime = new Date(group.createTime * 1000).toLocaleDateString();
|
||||
} else if (typeof group.createTime === "string") {
|
||||
// 已经是日期字符串,直接取日期部分
|
||||
formattedTime = group.createTime.split(" ")[0] || group.createTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.groupName,
|
||||
description: group.description || "",
|
||||
pic: group.groupIcon || "",
|
||||
type: group.isSystem, // 1=系统,0=自定义
|
||||
createTime: formattedTime,
|
||||
num: group.memberCount || 0,
|
||||
R: Math.round(group.avgRfmR || 0),
|
||||
F: Math.round(group.avgRfmF || 0),
|
||||
M: Math.round(group.avgRfmM || 0),
|
||||
RFM: group.avgRfmScore || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 关键字过滤
|
||||
if (params.keyword) {
|
||||
const keyword = params.keyword.toLowerCase();
|
||||
list = list.filter(
|
||||
item =>
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
(item.description && item.description.toLowerCase().includes(keyword)),
|
||||
);
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const total = list.length;
|
||||
const start = (params.page - 1) * params.pageSize;
|
||||
const end = start + params.pageSize;
|
||||
list = list.slice(start, end);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("获取流量池分组列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据包
|
||||
/**
|
||||
* 删除分组 (V2)
|
||||
*/
|
||||
export async function deletePackage(id: number): Promise<{ success: boolean }> {
|
||||
return request("/v1/traffic/pool/deletePackage", { id }, "POST");
|
||||
try {
|
||||
await deleteGroupV2(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("删除分组失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +1,205 @@
|
||||
.listWrap {
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 美团风格卡片样式 */
|
||||
/* Modern Card Style */
|
||||
.cardCompact {
|
||||
margin: 0 0 12px 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 16px 30px 16px 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 三点菜单按钮 */
|
||||
/* Three-dot Menu */
|
||||
.menuButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
z-index: 10;
|
||||
padding: 4px;
|
||||
color: #bfbfbf;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 左侧图片区域 */
|
||||
/* Icon/Image Box */
|
||||
.imageBox {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* 右侧内容区域 */
|
||||
/* Content Area */
|
||||
.contentArea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 标题行 */
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-right: 20px; /* Space for menu button */
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #262626;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 右侧标签区域 */
|
||||
.rightTags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deliveryTag {
|
||||
background: #fff7e6;
|
||||
color: #d46b08;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
.countTag {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffd591;
|
||||
}
|
||||
|
||||
.timeTag {
|
||||
color: #ff6b35;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 评分和销售信息 */
|
||||
.ratingRow {
|
||||
/* RFM Metrics */
|
||||
.metricsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rfmMain {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ff7a45;
|
||||
background: #fff2e8;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffbb96;
|
||||
}
|
||||
|
||||
.metricPill {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
background: #f5f5f5;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Info Row */
|
||||
.infoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
margin-top: 2px;
|
||||
|
||||
.typeTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #8c8c8c;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
color: #ff6b35;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Global styles for search bar in Layout */
|
||||
:global {
|
||||
.search-bar {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.sales {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
|
||||
.price {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.ant-input-affix-wrapper {
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
padding-left: 12px;
|
||||
|
||||
/* 配送信息 */
|
||||
.deliveryInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
input {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 中间标签 */
|
||||
.middleTag {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.ant-input-prefix {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highScoreTag {
|
||||
background: linear-gradient(135deg, #ff6b35, #ff8c42);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ant-btn {
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部按钮区域 */
|
||||
.bottomActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dineInBtn {
|
||||
background: transparent;
|
||||
border: 1px solid #52c41a;
|
||||
color: #52c41a;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.couponBtn {
|
||||
background: linear-gradient(135deg, #ff6b35, #ff8c42);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.couponText {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-left: 4px;
|
||||
.pagination-container {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -30,16 +30,28 @@ const getGroupIcon = (type: number, name?: string) => {
|
||||
return icons[type] || "👥";
|
||||
};
|
||||
|
||||
// 分组颜色映射
|
||||
// 分组颜色映射 - 使用更柔和的现代配色
|
||||
const getGroupColor = (type: number) => {
|
||||
const colors = {
|
||||
0: "#f0f0f0", // 灰色 - 自定义分组(使用名称首字符)
|
||||
1: "#ff4d4f", // 红色
|
||||
2: "#1890ff", // 蓝色
|
||||
3: "#52c41a", // 绿色
|
||||
4: "#722ed1", // 紫色
|
||||
0: "#F0F2F5", // 浅灰
|
||||
1: "#FFF1F0", // 浅红
|
||||
2: "#E6F7FF", // 浅蓝
|
||||
3: "#F6FFED", // 浅绿
|
||||
4: "#F9F0FF", // 浅紫
|
||||
};
|
||||
return colors[type] || "#1890ff";
|
||||
return colors[type] || "#E6F7FF";
|
||||
};
|
||||
|
||||
// 分组文字颜色映射
|
||||
const getGroupTextColor = (type: number) => {
|
||||
const colors = {
|
||||
0: "#8C8C8C",
|
||||
1: "#FF4D4F",
|
||||
2: "#1890FF",
|
||||
3: "#52C41A",
|
||||
4: "#722ED1",
|
||||
};
|
||||
return colors[type] || "#1890FF";
|
||||
};
|
||||
|
||||
const TrafficPoolList: React.FC = () => {
|
||||
@@ -50,23 +62,49 @@ const TrafficPoolList: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
// 防抖定时器
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 搜索输入变化时的防抖处理
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchInput(value);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器,500ms 后执行搜索
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setPage(1);
|
||||
// 触发数据重新获取
|
||||
const fetchData = async () => {
|
||||
fetchData(1);
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async (customPage?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: 1,
|
||||
page: customPage || page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
keyword,
|
||||
};
|
||||
|
||||
const res: PackageList = await getPackage(params);
|
||||
@@ -77,10 +115,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
};
|
||||
}, [page, pageSize, keyword]);
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
try {
|
||||
@@ -96,27 +131,8 @@ const TrafficPoolList: React.FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
};
|
||||
|
||||
const res: PackageList = await getPackage(params);
|
||||
setList(res?.list || []);
|
||||
setTotal(res?.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [page, pageSize, search]);
|
||||
}, [fetchData]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -143,8 +159,8 @@ const TrafficPoolList: React.FC = () => {
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索分组"
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
value={searchInput}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
@@ -173,7 +189,9 @@ const TrafficPoolList: React.FC = () => {
|
||||
>
|
||||
<div className={styles.listWrap}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description="暂无分组数据" />
|
||||
<div style={{ paddingTop: 60 }}>
|
||||
<Empty description="暂无分组数据" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{list.map(item => (
|
||||
@@ -194,12 +212,13 @@ const TrafficPoolList: React.FC = () => {
|
||||
`/mine/traffic-pool/userList/${item.id}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
// 只有非系统分组才显示删除选项
|
||||
...(item.type !== 1 ? [{
|
||||
key: "delete",
|
||||
danger: true,
|
||||
label: "删除数据包",
|
||||
onClick: () => handleDelete(item.id, item.name),
|
||||
},
|
||||
}] : []),
|
||||
],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
@@ -209,61 +228,51 @@ const TrafficPoolList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ display: "flex", gap: 10, flex: 1 }}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/mine/traffic-pool/userList/${item.id}`)
|
||||
}
|
||||
>
|
||||
{/* 左侧图片区域(优先展示 pic,缺省时使用假头像) */}
|
||||
{/* 左侧图片区域 */}
|
||||
<div
|
||||
className={styles.imageBox}
|
||||
style={{ background: getGroupColor(item.type) }}
|
||||
>
|
||||
{item.pic ? (
|
||||
<img
|
||||
src={item.pic}
|
||||
alt={item.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<img src={item.pic} alt={item.name} />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: getGroupTextColor(item.type) }}>
|
||||
{getGroupIcon(item.type, item.name)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧仅展示选中字段 */}
|
||||
{/* 右侧内容区域 */}
|
||||
<div className={styles.contentArea}>
|
||||
{/* 标题与人数 */}
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.title}>{item.name}</div>
|
||||
<div className={styles.timeTag}>共{item.num}人</div>
|
||||
<div className={styles.countTag}>{item.num}人</div>
|
||||
</div>
|
||||
|
||||
{/* RFM 汇总 */}
|
||||
<div className={styles.ratingRow}>
|
||||
<span className={styles.rating}>RFM:{item.RFM}</span>
|
||||
<span className={styles.sales}>
|
||||
R:{item.R} F:{item.F} M:{item.M}
|
||||
</span>
|
||||
<div className={styles.metricsRow}>
|
||||
<span className={styles.rfmMain}>RFM {item.RFM}</span>
|
||||
<span className={styles.metricPill}>R:{item.R}</span>
|
||||
<span className={styles.metricPill}>F:{item.F}</span>
|
||||
<span className={styles.metricPill}>M:{item.M}</span>
|
||||
</div>
|
||||
|
||||
{/* 类型与创建时间 */}
|
||||
<div className={styles.deliveryInfo}>
|
||||
<span>
|
||||
类型: {item.type === 0 ? "自定义" : "系统分组"}
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.typeTag}>
|
||||
{item.type === 0 ? "自定义" : "系统分组"}
|
||||
</span>
|
||||
<span className={styles.time}>
|
||||
{item.createTime?.split(" ")[0] || "-"}
|
||||
</span>
|
||||
<span>创建:{item.createTime || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,160 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
getPoolList,
|
||||
getGroups,
|
||||
createGroup,
|
||||
addMembersToGroup,
|
||||
type TrafficPoolGroup,
|
||||
type TrafficPoolMember,
|
||||
type PageResult,
|
||||
} from "../api";
|
||||
|
||||
// 获取流量池列表
|
||||
export function fetchTrafficPoolList(params: {
|
||||
// 兼容旧版返回格式
|
||||
export interface PoolListItem {
|
||||
id: number;
|
||||
identifier: string;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
phone: string | null;
|
||||
level: number;
|
||||
lifecycle: number;
|
||||
rfmR: number;
|
||||
rfmF: number;
|
||||
rfmM: number;
|
||||
rfmScore: number;
|
||||
totalMsgCount: number;
|
||||
totalOrderAmount: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface PoolListResult {
|
||||
list: PoolListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池列表 (V2)
|
||||
*/
|
||||
export async function fetchTrafficPoolList(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
}): Promise<PoolListResult> {
|
||||
try {
|
||||
const result = await getPoolList({
|
||||
page: params.page || 1,
|
||||
pageSize: params.pageSize || 10,
|
||||
keyword: params.keyword || "",
|
||||
});
|
||||
|
||||
const list: PoolListItem[] = result.list.map(item => ({
|
||||
id: item.id,
|
||||
identifier: item.identifier,
|
||||
nickname: item.nickname,
|
||||
avatar: item.avatar,
|
||||
phone: item.phone || item.mobile || null,
|
||||
level: item.level,
|
||||
lifecycle: item.lifecycle,
|
||||
rfmR: item.rfmR,
|
||||
rfmF: item.rfmF,
|
||||
rfmM: item.rfmM,
|
||||
rfmScore: item.rfmScore?.total || 0,
|
||||
totalMsgCount: item.totalMsgCount,
|
||||
totalOrderAmount: item.totalOrderAmount,
|
||||
tags: item.tags?.map(t => t.tagName) || [],
|
||||
}));
|
||||
|
||||
return {
|
||||
list,
|
||||
total: result.total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("获取流量池列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取获客场景列表
|
||||
*/
|
||||
export async function fetchScenarioOptions() {
|
||||
return request("/v1/plan/scenes", {}, "GET");
|
||||
}
|
||||
|
||||
export async function fetchPackageOptions() {
|
||||
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
||||
/**
|
||||
* 获取流量池分组选项 (V2)
|
||||
*/
|
||||
export async function fetchPackageOptions(): Promise<{
|
||||
list: Array<{ id: number; name: string; type: number; num: number }>;
|
||||
total: number;
|
||||
}> {
|
||||
try {
|
||||
const groups = await getGroups();
|
||||
const list = groups.map((g: TrafficPoolGroup) => ({
|
||||
id: g.id,
|
||||
name: g.groupName,
|
||||
type: g.isSystem,
|
||||
num: g.memberCount || 0,
|
||||
}));
|
||||
return { list, total: list.length };
|
||||
} catch (error) {
|
||||
console.error("获取流量池分组选项失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分组或添加成员到分组 (V2)
|
||||
*/
|
||||
export async function addPackage(params: {
|
||||
type: string; // 类型 1搜索 2选择用户 3文件上传
|
||||
addPackageId?: number;
|
||||
addPackageId?: number; // 要添加到的分组ID
|
||||
addStatus?: number;
|
||||
deviceId?: string;
|
||||
keyword?: string;
|
||||
packageId?: number;
|
||||
packageName?: number; // 添加的流量池名称
|
||||
packageName?: string; // 添加的流量池名称
|
||||
tableFile?: number;
|
||||
taskId?: number; // 任务id j及场景获客id
|
||||
userIds?: number[];
|
||||
taskId?: number;
|
||||
userIds?: number[]; // 要添加的用户ID列表
|
||||
userValue?: number;
|
||||
}) {
|
||||
return request("/v1/traffic/pool/addPackage", params, "POST");
|
||||
try {
|
||||
// 如果是添加成员到现有分组
|
||||
if (params.addPackageId && params.userIds && params.userIds.length > 0) {
|
||||
const result = await addMembersToGroup(params.addPackageId, params.userIds);
|
||||
return { success: true, count: result.count };
|
||||
}
|
||||
|
||||
// 如果是创建新分组
|
||||
if (params.packageName) {
|
||||
const result = await createGroup({
|
||||
groupName: String(params.packageName),
|
||||
ruleType: 2, // 手动添加类型
|
||||
});
|
||||
|
||||
// 如果有要添加的用户
|
||||
if (params.userIds && params.userIds.length > 0) {
|
||||
await addMembersToGroup(result.id, params.userIds);
|
||||
}
|
||||
|
||||
return { success: true, id: result.id };
|
||||
}
|
||||
|
||||
throw new Error("缺少必要参数");
|
||||
} catch (error) {
|
||||
console.error("创建分组或添加成员失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出V2 API供直接使用
|
||||
export {
|
||||
getGroups,
|
||||
createGroup,
|
||||
addMembersToGroup,
|
||||
removeMembersFromGroup,
|
||||
getPoolDetail,
|
||||
updatePool,
|
||||
getStatistics,
|
||||
} from "../api";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -29,7 +29,11 @@ const TrafficPoolList: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
// 防抖定时器
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 筛选相关
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
@@ -51,6 +55,31 @@ const TrafficPoolList: React.FC = () => {
|
||||
// 数据分析
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
|
||||
// 搜索输入变化时的防抖处理
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchInput(value);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器,500ms 后执行搜索
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取列表
|
||||
const getList = async (customParams?: any) => {
|
||||
setLoading(true);
|
||||
@@ -58,7 +87,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
const params: any = {
|
||||
page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
keyword,
|
||||
packageld: filterParams.packageId,
|
||||
sceneId: filterParams.scenarioId,
|
||||
userValue: filterParams.userValue,
|
||||
@@ -87,6 +116,11 @@ const TrafficPoolList: React.FC = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 参数变化时重新获取数据
|
||||
useEffect(() => {
|
||||
getList();
|
||||
}, [page, pageSize, keyword, filterParams]);
|
||||
|
||||
// 全选/反选
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
@@ -127,7 +161,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
...(filterParams.selectedDevices.length > 0 && {
|
||||
deviceId: filterParams.selectedDevices.map(d => d.id).join(","),
|
||||
}),
|
||||
...(search && { keyword: search }),
|
||||
...(keyword && { keyword }),
|
||||
};
|
||||
|
||||
console.log("批量加入请求参数:", params);
|
||||
@@ -155,29 +189,9 @@ const TrafficPoolList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索防抖处理
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
|
||||
const debouncedSearch = useCallback(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearch(searchInput);
|
||||
// 搜索时重置到第一页并请求列表
|
||||
setPage(1);
|
||||
getList({ keyword: searchInput, page: 1 });
|
||||
}, 500); // 500ms 防抖延迟
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = debouncedSearch();
|
||||
return cleanup;
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handSearch = (value: string) => {
|
||||
setSearchInput(value);
|
||||
handleSearchChange(value);
|
||||
setSelectedIds([]);
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,136 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
getGroupMembers,
|
||||
getPoolList,
|
||||
type TrafficPoolMember,
|
||||
type PageResult,
|
||||
SOURCE_TYPE_TEXT,
|
||||
} from "../api";
|
||||
|
||||
// 获取流量包用户列表
|
||||
export function fetchTrafficPoolList(params: {
|
||||
// 用户列表项(兼容前端页面使用的字段)
|
||||
export interface UserListItem {
|
||||
id: number;
|
||||
identifier: string;
|
||||
companyId: number;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
gender: number;
|
||||
phone: string | null;
|
||||
wechatId: string | null; // 微信号(用于显示)
|
||||
fromd: string; // 来源(中文)
|
||||
packages: string[]; // 分组名称
|
||||
createTime: string; // 创建时间
|
||||
// 其他字段
|
||||
alias: string | null;
|
||||
tags: string[];
|
||||
region: string;
|
||||
lifecycle: number;
|
||||
intentionLevel: number;
|
||||
R: number;
|
||||
F: number;
|
||||
M: number;
|
||||
RFM: number;
|
||||
money: number;
|
||||
msgCount: number;
|
||||
}
|
||||
|
||||
export interface UserListResult {
|
||||
list: UserListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池用户列表(V2版本)
|
||||
* 支持按分组ID或全量查询
|
||||
*/
|
||||
export async function fetchTrafficPoolList(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
packageId?: string;
|
||||
}) {
|
||||
return request("/v1/traffic/pool/users", params, "GET");
|
||||
packageId?: string; // 分组ID,兼容旧版参数名
|
||||
groupId?: number; // 新版参数名
|
||||
}): Promise<UserListResult> {
|
||||
const page = params.page || 1;
|
||||
const pageSize = params.pageSize || 10;
|
||||
const keyword = params.keyword || "";
|
||||
const groupId = params.groupId || (params.packageId ? parseInt(params.packageId) : 0);
|
||||
|
||||
let result: PageResult<TrafficPoolMember>;
|
||||
|
||||
if (groupId && groupId > 0) {
|
||||
// 按分组查询
|
||||
result = await getGroupMembers({
|
||||
groupId,
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
});
|
||||
} else {
|
||||
// 全量查询
|
||||
result = await getPoolList({
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
});
|
||||
}
|
||||
|
||||
// 转换为前端页面使用的格式
|
||||
const list: UserListItem[] = result.list.map((item: any) => ({
|
||||
id: item.id,
|
||||
identifier: item.identifier,
|
||||
companyId: item.companyId,
|
||||
nickname: item.nickname || null,
|
||||
avatar: item.avatar || null,
|
||||
gender: item.gender || 0,
|
||||
phone: item.phone || item.mobile || null,
|
||||
// 微信号:优先显示 wechatAlias,如果没有则显示 wechatId
|
||||
wechatId: item.wechatAlias || item.wechatId || null,
|
||||
// 来源:将数字转换为中文
|
||||
fromd: SOURCE_TYPE_TEXT[item.firstSourceType] || "未知来源",
|
||||
// 分组:暂时为空,后续可扩展
|
||||
packages: [],
|
||||
// 创建时间
|
||||
createTime: item.createTime || "",
|
||||
// 其他字段
|
||||
alias: item.wechatAlias || null,
|
||||
tags: item.tags?.map((t: any) => t.tagName) || [],
|
||||
region: item.region || "",
|
||||
lifecycle: item.lifecycle || 1,
|
||||
intentionLevel: item.intentionLevel || 0,
|
||||
R: item.rfmScore?.R || 0,
|
||||
F: item.rfmScore?.F || 0,
|
||||
M: item.rfmScore?.M || 0,
|
||||
RFM: item.rfmScore?.total || 0,
|
||||
money: item.totalOrderAmount || 0,
|
||||
msgCount: item.totalMsgCount || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
list,
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*/
|
||||
export { getPoolDetail as fetchUserDetail } from "../api";
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
export { updatePool as updateUser } from "../api";
|
||||
|
||||
/**
|
||||
* 获取用户标签
|
||||
*/
|
||||
export { getPoolTags as fetchUserTags } from "../api";
|
||||
|
||||
/**
|
||||
* 添加用户标签
|
||||
*/
|
||||
export { addTag } from "../api";
|
||||
|
||||
/**
|
||||
* 移除用户标签
|
||||
*/
|
||||
export { removeTag } from "../api";
|
||||
|
||||
@@ -2,20 +2,27 @@
|
||||
export interface TrafficPoolUser {
|
||||
id: number;
|
||||
identifier: string;
|
||||
mobile: string;
|
||||
wechatId: string;
|
||||
fromd: string;
|
||||
status: number;
|
||||
createTime: string;
|
||||
companyId: number;
|
||||
sourceId: string;
|
||||
type: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
gender: number;
|
||||
phone: string;
|
||||
packages: string[];
|
||||
phone: string | null;
|
||||
wechatId: string | null; // 微信号(用于显示)
|
||||
fromd: string; // 来源(中文)
|
||||
packages: string[]; // 分组名称
|
||||
createTime: string; // 创建时间
|
||||
// 其他字段
|
||||
alias: string | null;
|
||||
tags: string[];
|
||||
region: string; // 地区
|
||||
lifecycle: number; // 生命周期
|
||||
intentionLevel: number; // 意向等级
|
||||
R: number;
|
||||
F: number;
|
||||
M: number;
|
||||
RFM: number;
|
||||
money: number;
|
||||
msgCount: number;
|
||||
}
|
||||
|
||||
// 列表响应类型
|
||||
|
||||
@@ -1,40 +1,183 @@
|
||||
.listWrap {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f6f7f9;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.cardWrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
transition: transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.avatar {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.mainInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.nicknameRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.nickname {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.wechatId {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
.statsRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fbff;
|
||||
border-radius: 8px;
|
||||
|
||||
.statItem {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.statValue {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
.tagsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
min-height: 20px;
|
||||
align-items: center;
|
||||
|
||||
.tagItem {
|
||||
padding: 2px 8px;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
border: 1px solid #d6e4ff;
|
||||
}
|
||||
|
||||
.moreTags {
|
||||
font-size: 11px;
|
||||
color: #bfbfbf;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.noTags {
|
||||
font-size: 11px;
|
||||
color: #d9d9d9;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.footerRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
|
||||
.source {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索栏样式优化
|
||||
:global {
|
||||
.search-bar {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
.ant-input-affix-wrapper {
|
||||
border-radius: 20px;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,75 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input, Button, Pagination } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
MessageOutlined,
|
||||
DollarOutlined,
|
||||
AreaChartOutlined,
|
||||
UserOutlined,
|
||||
EnvironmentOutlined,
|
||||
WomanOutlined,
|
||||
ManOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Input, Button, Pagination, Tag } from "antd";
|
||||
import styles from "./index.module.scss";
|
||||
import { Empty, Avatar } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchTrafficPoolList } from "./api";
|
||||
import { LIFECYCLE_TEXT } from "../api";
|
||||
import type { TrafficPoolUser } from "./data";
|
||||
|
||||
const defaultAvatar =
|
||||
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||
const defaultAvatar = "https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||
|
||||
const TrafficPoolUserList: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 基础状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
// 获取列表
|
||||
const getList = async (customParams?: any) => {
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getList = useCallback(async (customParams?: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentPage = customParams?.page || page;
|
||||
const params: any = {
|
||||
page,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
packageId: id, // 根据流量包ID筛选用户
|
||||
...customParams, // 允许传入自定义参数覆盖
|
||||
keyword,
|
||||
packageId: id,
|
||||
...customParams,
|
||||
};
|
||||
|
||||
const res = await fetchTrafficPoolList(params);
|
||||
setList(res.list || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (error) {
|
||||
// 忽略请求过于频繁的错误,避免页面崩溃
|
||||
if (error !== "请求过于频繁,请稍后再试") {
|
||||
console.error("获取列表失败:", error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, pageSize, keyword, id]);
|
||||
|
||||
// 搜索防抖处理
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
|
||||
const debouncedSearch = useCallback(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearch(searchInput);
|
||||
// 搜索时重置到第一页并请求列表
|
||||
setPage(1);
|
||||
getList({ keyword: searchInput, page: 1 });
|
||||
}, 500); // 500ms 防抖延迟
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = debouncedSearch();
|
||||
return cleanup;
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handSearch = (value: string) => {
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchInput(value);
|
||||
debouncedSearch();
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 初始加载和参数变化时重新获取数据
|
||||
useEffect(() => {
|
||||
getList();
|
||||
}, [page, pageSize, search, id]);
|
||||
getList({ page: 1 });
|
||||
}, [keyword, id]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -84,14 +77,13 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
header={
|
||||
<>
|
||||
<NavCommon title="用户列表" />
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索用户"
|
||||
placeholder="搜索用户姓名/微信号"
|
||||
value={searchInput}
|
||||
onChange={e => handSearch(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
@@ -99,7 +91,6 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
<Button
|
||||
onClick={() => getList()}
|
||||
loading={loading}
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
/>
|
||||
</div>
|
||||
@@ -108,10 +99,10 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
footer={
|
||||
<div className="pagination-container">
|
||||
<Pagination
|
||||
simple
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger={false}
|
||||
onChange={newPage => {
|
||||
setPage(newPage);
|
||||
getList({ page: newPage });
|
||||
@@ -124,45 +115,89 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description="暂无用户数据" />
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.listContainer}>
|
||||
{list.map(item => (
|
||||
<div key={item.id} className={styles.cardWrap}>
|
||||
<div
|
||||
className={styles.card}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`,
|
||||
)
|
||||
}
|
||||
<div
|
||||
key={item.id}
|
||||
className={styles.cardWrap}
|
||||
onClick={() => navigate(`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`)}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
{/* 顶部:头像与基本信息 */}
|
||||
<div className={styles.cardHeader}>
|
||||
<Avatar
|
||||
src={item.avatar || defaultAvatar}
|
||||
style={{ "--size": "60px" }}
|
||||
className={styles.avatar}
|
||||
style={{ "--size": "52px" }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.mainInfo}>
|
||||
<div className={styles.nicknameRow}>
|
||||
<span className={styles.nickname}>
|
||||
{item.nickname || item.identifier}
|
||||
{item.gender === 1 ? (
|
||||
<ManOutlined style={{ color: "#1677ff", fontSize: "12px", marginLeft: "4px" }} />
|
||||
) : item.gender === 2 ? (
|
||||
<WomanOutlined style={{ color: "#ff4d4f", fontSize: "12px", marginLeft: "4px" }} />
|
||||
) : null}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<Tag color="blue" style={{ borderRadius: '4px', fontSize: '10px', border: 'none', margin: 0 }}>
|
||||
{LIFECYCLE_TEXT[item.lifecycle] || "新流量"}
|
||||
</Tag>
|
||||
<Tag color="orange" style={{ borderRadius: '4px', fontSize: '10px', border: 'none', margin: 0 }}>
|
||||
RFM: {item.RFM}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
微信号:{item.wechatId || "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
来源:{item.fromd || "-"}
|
||||
<div className={styles.wechatId}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<UserOutlined style={{ fontSize: '12px' }} />
|
||||
{item.wechatId || "未设置微信号"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
分组:
|
||||
{item.packages && item.packages.length
|
||||
? item.packages.join(",")
|
||||
: "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
创建时间:{item.createTime}
|
||||
{item.region && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', color: '#bfbfbf', fontSize: '11px' }}>
|
||||
<EnvironmentOutlined />
|
||||
{item.region}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:互动统计小色块 */}
|
||||
<div className={styles.statsRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}><MessageOutlined /> 消息</span>
|
||||
<span className={styles.statValue}>{item.msgCount || 0}</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}><DollarOutlined /> 成交</span>
|
||||
<span className={styles.statValue}>¥{item.money || 0}</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}><AreaChartOutlined /> 活跃度</span>
|
||||
<span className={styles.statValue}>{item.F || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:用户标签 */}
|
||||
<div className={styles.tagsRow}>
|
||||
{item.tags && item.tags.length > 0 ? (
|
||||
<>
|
||||
{item.tags.slice(0, 3).map((tag, idx) => (
|
||||
<span key={idx} className={styles.tagItem}>{tag}</span>
|
||||
))}
|
||||
{item.tags.length > 3 && <span className={styles.moreTags}>+{item.tags.length - 3}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.noTags}>暂无标签</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 脚注:来源与时间 */}
|
||||
<div className={styles.footerRow}>
|
||||
<span className={styles.source}>{item.fromd}</span>
|
||||
<span>{item.createTime?.split(' ')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -49,10 +49,26 @@ export function transferWechatFriends(params: {
|
||||
wechatId: string;
|
||||
devices: number[];
|
||||
inherit: boolean;
|
||||
greeting?: string;
|
||||
firstMessage?: string;
|
||||
}) {
|
||||
return request("/v1/wechats/transfer-friends", params, "POST");
|
||||
}
|
||||
|
||||
// 获取客服账号列表
|
||||
export function getKefuAccountsList() {
|
||||
return request("/v1/kefu/accounts/list", {}, "GET");
|
||||
}
|
||||
|
||||
// 转移好友到客服账号
|
||||
export function transferFriend(params: {
|
||||
friendId: string;
|
||||
toAccountId: string;
|
||||
comment?: string;
|
||||
}) {
|
||||
return request("/v1/friend/transfer", params, "POST");
|
||||
}
|
||||
|
||||
// 导出朋友圈接口(直接下载文件)
|
||||
export async function exportWechatMoments(params: {
|
||||
wechatId: string;
|
||||
@@ -92,6 +108,20 @@ export async function exportWechatMoments(params: {
|
||||
}
|
||||
);
|
||||
|
||||
// 检查响应类型,如果是JSON错误响应,需要解析错误信息
|
||||
const contentType = response.headers["content-type"] || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
// 如果是JSON响应,说明可能是错误信息
|
||||
const text = await response.data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
throw new Error(errorData.message || errorData.msg || "导出失败");
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`导出失败,状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
@@ -114,6 +144,32 @@ export async function exportWechatMoments(params: {
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.message || error.message || "导出失败");
|
||||
// 如果是我们抛出的错误,直接抛出
|
||||
if (error.message && error.message !== "导出失败") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 处理axios错误响应
|
||||
if (error.response) {
|
||||
// 如果响应是blob类型,尝试读取为文本
|
||||
if (error.response.data instanceof Blob) {
|
||||
try {
|
||||
const text = await error.response.data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
throw new Error(errorData.message || errorData.msg || "导出失败");
|
||||
} catch (parseError) {
|
||||
throw new Error("导出失败,请重试");
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
error.response.data?.message ||
|
||||
error.response.data?.msg ||
|
||||
error.message ||
|
||||
"导出失败"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(error.message || "导出失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +102,12 @@ export interface WechatAccountSummary {
|
||||
|
||||
export interface Friend {
|
||||
id: string;
|
||||
friendId?: string;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
accountUserName: string;
|
||||
accountRealName: string;
|
||||
remark: string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
|
||||
@@ -339,8 +339,8 @@
|
||||
height: 5px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
background: #1677ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon-chat {
|
||||
width: 20px;
|
||||
@@ -373,6 +373,12 @@
|
||||
font-weight: 600;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-value-negative {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,99 +684,114 @@
|
||||
|
||||
.friend-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #cfe2ff;
|
||||
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.15);
|
||||
&:active {
|
||||
background: #f8f9fa;
|
||||
border-color: #1677ff;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.friend-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
|
||||
.adm-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.friend-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.friend-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-size: 15px;
|
||||
.friend-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
flex-shrink: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.friend-tags {
|
||||
.friend-value {
|
||||
flex-shrink: 0;
|
||||
|
||||
.value-amount {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fa541c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.friend-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.friend-info-item {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.info-label {
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #666;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.friend-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.friend-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.friend-id-row {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.friend-status-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.friend-status-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: #f0f7ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.friend-value {
|
||||
text-align: right;
|
||||
|
||||
.value-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fa541c;
|
||||
}
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,6 +872,93 @@
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.friend-info-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
|
||||
.friend-info-text {
|
||||
flex: 1;
|
||||
|
||||
.friend-info-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.friend-info-id {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-accounts,
|
||||
.empty-accounts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kefu-accounts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
.kefu-account-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
flex: 1;
|
||||
|
||||
.account-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.account-id {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-icon {
|
||||
color: #1677ff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-form {
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -868,9 +976,10 @@
|
||||
.type-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
|
||||
.type-option {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
@@ -879,6 +988,7 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
@@ -1331,81 +1441,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
.moments-content {
|
||||
padding: 16px 0;
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #f5f5f5;
|
||||
|
||||
.moments-action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px 16px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.action-button, .action-button-dark {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 70px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #1677ff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: white;
|
||||
|
||||
.action-icon-text, .action-icon-image, .action-icon-video, .action-icon-export {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background: white;
|
||||
}
|
||||
&:active {
|
||||
background: #0958d9;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.action-icon-image::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.action-icon-video::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 5px 0 5px 8px;
|
||||
border-color: transparent transparent transparent white;
|
||||
}
|
||||
|
||||
.action-text, .action-text-light {
|
||||
font-size: 12px;
|
||||
svg {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.action-button-dark {
|
||||
background: #333;
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
|
||||
&:active {
|
||||
background: #0958d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moments-content {
|
||||
padding: 16px 0;
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #f5f5f5;
|
||||
|
||||
.moments-list {
|
||||
padding: 0 16px;
|
||||
@@ -1459,7 +1541,7 @@
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
// 1张图片:宽度拉伸,高度自适应
|
||||
@@ -1469,56 +1551,57 @@
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 400px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 2张图片:左右并列
|
||||
// 2张图片:左右并列,1:1比例
|
||||
&.double {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 3张图片:三张并列
|
||||
// 3张图片:三张并列,1:1比例
|
||||
&.triple {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 4张图片:2x2网格布局
|
||||
// 4张图片:2x2网格布局,1:1比例
|
||||
&.quad {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 5张及以上:网格布局(9宫格)
|
||||
// 5张及以上:网格布局(9宫格),1:1比例
|
||||
&.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.image-more {
|
||||
@@ -1526,11 +1609,11 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: 100px;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1547,6 +1630,34 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moments-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.moments-no-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.friends-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.friends-no-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,18 @@ import {
|
||||
Tag,
|
||||
Switch,
|
||||
DatePicker,
|
||||
InfiniteScroll,
|
||||
} from "antd-mobile";
|
||||
import { Input, Pagination } from "antd";
|
||||
import { Input, Select } from "antd";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
UserOutlined,
|
||||
FileTextOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
DownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./detail.module.scss";
|
||||
@@ -29,9 +34,12 @@ import {
|
||||
getWechatAccountOverview,
|
||||
getWechatMoments,
|
||||
exportWechatMoments,
|
||||
getKefuAccountsList,
|
||||
transferFriend,
|
||||
} from "./api";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { WechatAccountSummary, Friend, MomentItem } from "./data";
|
||||
|
||||
@@ -47,6 +55,8 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
|
||||
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [inheritInfo, setInheritInfo] = useState(true);
|
||||
const [greeting, setGreeting] = useState("");
|
||||
const [firstMessage, setFirstMessage] = useState("");
|
||||
const [transferLoading, setTransferLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
@@ -76,6 +86,15 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const [showEndTimePicker, setShowEndTimePicker] = useState(false);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
|
||||
// 迁移好友相关状态
|
||||
const [showTransferFriendPopup, setShowTransferFriendPopup] = useState(false);
|
||||
const [selectedFriend, setSelectedFriend] = useState<Friend | null>(null);
|
||||
const [kefuAccounts, setKefuAccounts] = useState<any[]>([]);
|
||||
const [selectedKefuAccountId, setSelectedKefuAccountId] = useState<string>("");
|
||||
const [transferComment, setTransferComment] = useState<string>("");
|
||||
const [transferFriendLoading, setTransferFriendLoading] = useState(false);
|
||||
const [loadingKefuAccounts, setLoadingKefuAccounts] = useState(false);
|
||||
|
||||
// 获取基础信息
|
||||
const fetchAccountInfo = useCallback(async () => {
|
||||
if (!id) return;
|
||||
@@ -105,6 +124,84 @@ const WechatAccountDetail: React.FC = () => {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 计算账号价值
|
||||
// 规则:
|
||||
// 1. 1个好友3块
|
||||
// 2. 1个群1块
|
||||
// 3. 修改过微信号10块
|
||||
const calculateAccountValue = useCallback(() => {
|
||||
// 获取好友数量(优先使用概览数据,其次使用好友列表总数,最后使用账号信息)
|
||||
const friendsCount = overviewData?.totalFriends || friendsTotal || accountInfo?.friendShip?.totalFriend || 0;
|
||||
|
||||
// 获取群数量(优先使用概览数据,其次使用账号信息)
|
||||
const groupsCount = overviewData?.highValueChatrooms || accountInfo?.friendShip?.groupNumber || 0;
|
||||
|
||||
// 判断是否修改过微信号
|
||||
// 注意:需要根据实际API返回的字段来判断,可能的字段名:
|
||||
// - isWechatIdModified (布尔值)
|
||||
// - wechatIdModified (布尔值)
|
||||
// - hasModifiedWechatId (布尔值)
|
||||
// - wechatIdChangeCount (数字,大于0表示修改过)
|
||||
// 如果API没有返回该字段,需要后端添加或根据其他逻辑判断
|
||||
const isWechatIdModified =
|
||||
accountInfo?.isWechatIdModified ||
|
||||
accountInfo?.wechatIdModified ||
|
||||
accountInfo?.hasModifiedWechatId ||
|
||||
(accountInfo?.wechatIdChangeCount && accountInfo.wechatIdChangeCount > 0) ||
|
||||
false;
|
||||
|
||||
// 计算各部分价值
|
||||
const friendsValue = friendsCount * 3; // 好友数 * 3
|
||||
const groupsValue = groupsCount * 1; // 群数 * 1
|
||||
const wechatIdModifiedValue = isWechatIdModified ? 10 : 0; // 修改过微信号 ? 10 : 0
|
||||
|
||||
// 计算总价值
|
||||
const totalValue = friendsValue + groupsValue + wechatIdModifiedValue;
|
||||
|
||||
return {
|
||||
value: totalValue,
|
||||
formatted: `¥${totalValue.toLocaleString()}`,
|
||||
breakdown: {
|
||||
friends: friendsValue,
|
||||
groups: groupsValue,
|
||||
wechatIdModified: wechatIdModifiedValue,
|
||||
friendsCount,
|
||||
groupsCount,
|
||||
isWechatIdModified,
|
||||
},
|
||||
};
|
||||
}, [overviewData, friendsTotal, accountInfo]);
|
||||
|
||||
// 计算今日价值变化
|
||||
// 规则:
|
||||
// 1. 今日新增好友 * 3块
|
||||
// 2. 今日新增群 * 1块
|
||||
const calculateTodayValueChange = useCallback(() => {
|
||||
// 获取今日新增好友数
|
||||
const todayNewFriends = overviewData?.todayNewFriends || accountSummary?.statistics?.todayAdded || 0;
|
||||
|
||||
// 获取今日新增群数
|
||||
const todayNewChatrooms = overviewData?.todayNewChatrooms || 0;
|
||||
|
||||
// 计算今日价值变化
|
||||
const friendsValueChange = todayNewFriends * 3; // 今日新增好友数 * 3
|
||||
const groupsValueChange = todayNewChatrooms * 1; // 今日新增群数 * 1
|
||||
|
||||
const totalChange = friendsValueChange + groupsValueChange;
|
||||
|
||||
return {
|
||||
change: totalChange,
|
||||
formatted: totalChange >= 0 ? `+${totalChange.toLocaleString()}` : `${totalChange.toLocaleString()}`,
|
||||
isPositive: totalChange >= 0,
|
||||
breakdown: {
|
||||
friends: friendsValueChange,
|
||||
groups: groupsValueChange,
|
||||
todayNewFriends,
|
||||
todayNewChatrooms,
|
||||
},
|
||||
};
|
||||
}, [overviewData, accountSummary]);
|
||||
|
||||
// 获取概览数据
|
||||
const fetchOverviewData = useCallback(async () => {
|
||||
if (!id) return;
|
||||
@@ -120,7 +217,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
|
||||
// 获取好友列表 - 封装为独立函数
|
||||
const fetchFriendsList = useCallback(
|
||||
async (page: number = 1, keyword: string = "") => {
|
||||
async (page: number = 1, keyword: string = "", append: boolean = false) => {
|
||||
if (!id) return;
|
||||
|
||||
setIsFetchingFriends(true);
|
||||
@@ -130,7 +227,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const response = await getWechatFriends({
|
||||
wechatAccount: id,
|
||||
page: page,
|
||||
limit: 5,
|
||||
limit: 20,
|
||||
keyword: keyword,
|
||||
});
|
||||
|
||||
@@ -151,9 +248,12 @@ const WechatAccountDetail: React.FC = () => {
|
||||
|
||||
return {
|
||||
id: friend.id.toString(),
|
||||
friendId: friend.friendId || friend.id?.toString() || "",
|
||||
avatar: friend.avatar || "/placeholder.svg",
|
||||
nickname: friend.nickname || "未知用户",
|
||||
wechatId: friend.wechatId || "",
|
||||
accountUserName: friend.accountUserName || "",
|
||||
accountRealName: friend.accountRealName || "",
|
||||
remark: friend.notes || "",
|
||||
addTime:
|
||||
friend.createTime || new Date().toISOString().split("T")[0],
|
||||
@@ -173,15 +273,17 @@ const WechatAccountDetail: React.FC = () => {
|
||||
};
|
||||
});
|
||||
|
||||
setFriends(newFriends);
|
||||
setFriends(prev => (append ? [...prev, ...newFriends] : newFriends));
|
||||
setFriendsTotal(response.total);
|
||||
setFriendsPage(page);
|
||||
setIsFriendsEmpty(newFriends.length === 0);
|
||||
setIsFriendsEmpty(newFriends.length === 0 && !append);
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
setHasFriendLoadError(true);
|
||||
setFriends([]);
|
||||
setIsFriendsEmpty(true);
|
||||
if (!append) {
|
||||
setFriends([]);
|
||||
setIsFriendsEmpty(true);
|
||||
}
|
||||
Toast.show({
|
||||
content: "获取好友列表失败,请检查网络连接",
|
||||
position: "top",
|
||||
@@ -236,22 +338,20 @@ const WechatAccountDetail: React.FC = () => {
|
||||
// 搜索好友
|
||||
const handleSearch = useCallback(() => {
|
||||
setFriendsPage(1);
|
||||
fetchFriendsList(1, searchQuery);
|
||||
fetchFriendsList(1, searchQuery, false);
|
||||
}, [searchQuery, fetchFriendsList]);
|
||||
|
||||
// 刷新好友列表
|
||||
const handleRefreshFriends = useCallback(() => {
|
||||
fetchFriendsList(friendsPage, searchQuery);
|
||||
fetchFriendsList(friendsPage, searchQuery, false);
|
||||
}, [friendsPage, searchQuery, fetchFriendsList]);
|
||||
|
||||
// 分页切换
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
setFriendsPage(page);
|
||||
fetchFriendsList(page, searchQuery);
|
||||
},
|
||||
[searchQuery, fetchFriendsList],
|
||||
);
|
||||
// 加载更多好友
|
||||
const handleLoadMoreFriends = async () => {
|
||||
if (isFetchingFriends) return;
|
||||
if (friends.length >= friendsTotal) return;
|
||||
await fetchFriendsList(friendsPage + 1, searchQuery, true);
|
||||
};
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
@@ -266,7 +366,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
if (activeTab === "friends" && id) {
|
||||
setIsFriendsEmpty(false);
|
||||
setHasFriendLoadError(false);
|
||||
fetchFriendsList(1, searchQuery);
|
||||
fetchFriendsList(1, searchQuery, false);
|
||||
}
|
||||
}, [activeTab, id, fetchFriendsList, searchQuery]);
|
||||
|
||||
@@ -294,6 +394,10 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const handleTransferFriends = () => {
|
||||
setSelectedDevices([]);
|
||||
setInheritInfo(true);
|
||||
// 设置默认打招呼内容,使用当前微信账号昵称
|
||||
const nickname = accountInfo?.nickname || "未知";
|
||||
setGreeting(`我是${nickname}的新号,请通过`);
|
||||
setFirstMessage("这个是我的新号,重新加你一下,以后业务就用这个号!");
|
||||
setShowTransferConfirm(true);
|
||||
};
|
||||
|
||||
@@ -321,7 +425,9 @@ const WechatAccountDetail: React.FC = () => {
|
||||
await transferWechatFriends({
|
||||
wechatId: id,
|
||||
devices: selectedDevices.map(device => device.id),
|
||||
inherit: inheritInfo
|
||||
inherit: inheritInfo,
|
||||
greeting: greeting.trim(),
|
||||
firstMessage: firstMessage.trim()
|
||||
});
|
||||
|
||||
Toast.show({
|
||||
@@ -330,6 +436,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
});
|
||||
setShowTransferConfirm(false);
|
||||
setSelectedDevices([]);
|
||||
setFirstMessage("");
|
||||
navigate("/scenarios");
|
||||
} catch (error) {
|
||||
console.error("好友转移失败:", error);
|
||||
@@ -373,13 +480,72 @@ const WechatAccountDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleFriendClick = (friend: Friend) => {
|
||||
navigate(`/mine/traffic-pool/detail/${friend.wechatId}/${friend.id}`);
|
||||
setSelectedFriend(friend);
|
||||
setShowTransferFriendPopup(true);
|
||||
// 加载客服账号列表
|
||||
fetchKefuAccounts();
|
||||
};
|
||||
|
||||
const handleLoadMoreMoments = () => {
|
||||
// 获取客服账号列表
|
||||
const fetchKefuAccounts = useCallback(async () => {
|
||||
setLoadingKefuAccounts(true);
|
||||
try {
|
||||
const response = await getKefuAccountsList();
|
||||
// 数据结构:{ code: 200, msg: "success", data: { total: 7, list: [...] } }
|
||||
const accountsList = response?.data?.list || response?.list || (Array.isArray(response) ? response : []);
|
||||
setKefuAccounts(accountsList);
|
||||
} catch (error) {
|
||||
console.error("获取客服账号列表失败:", error);
|
||||
Toast.show({
|
||||
content: "获取客服账号列表失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoadingKefuAccounts(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 确认转移好友
|
||||
const handleConfirmTransferFriend = async () => {
|
||||
if (!selectedFriend) {
|
||||
Toast.show({ content: "请选择好友", position: "top" });
|
||||
return;
|
||||
}
|
||||
if (!selectedKefuAccountId) {
|
||||
Toast.show({ content: "请选择目标客服账号", position: "top" });
|
||||
return;
|
||||
}
|
||||
|
||||
setTransferFriendLoading(true);
|
||||
try {
|
||||
await transferFriend({
|
||||
friendId: selectedFriend.friendId || selectedFriend.id,
|
||||
toAccountId: selectedKefuAccountId,
|
||||
comment: transferComment || undefined,
|
||||
});
|
||||
|
||||
Toast.show({ content: "转移成功", position: "top" });
|
||||
setShowTransferFriendPopup(false);
|
||||
setSelectedFriend(null);
|
||||
setSelectedKefuAccountId("");
|
||||
setTransferComment("");
|
||||
// 刷新好友列表
|
||||
fetchFriendsList(1, searchQuery, false);
|
||||
} catch (error: any) {
|
||||
console.error("转移好友失败:", error);
|
||||
Toast.show({
|
||||
content: error?.message || "转移失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setTransferFriendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMoreMoments = async () => {
|
||||
if (isFetchingMoments) return;
|
||||
if (moments.length >= momentsTotal) return;
|
||||
fetchMomentsList(momentsPage + 1, true);
|
||||
await fetchMomentsList(momentsPage + 1, true);
|
||||
};
|
||||
|
||||
// 处理朋友圈导出
|
||||
@@ -389,6 +555,18 @@ const WechatAccountDetail: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证时间范围不超过1个月
|
||||
if (exportStartTime && exportEndTime) {
|
||||
const maxDate = dayjs(exportStartTime).add(1, "month").toDate();
|
||||
if (exportEndTime > maxDate) {
|
||||
Toast.show({
|
||||
content: "日期范围不能超过1个月",
|
||||
position: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// 格式化时间
|
||||
@@ -409,18 +587,27 @@ const WechatAccountDetail: React.FC = () => {
|
||||
});
|
||||
|
||||
Toast.show({ content: "导出成功", position: "top" });
|
||||
setShowExportPopup(false);
|
||||
// 重置筛选条件
|
||||
// 重置筛选条件(先重置,再关闭弹窗)
|
||||
setExportKeyword("");
|
||||
setExportType(undefined);
|
||||
setExportStartTime(null);
|
||||
setExportEndTime(null);
|
||||
setShowStartTimePicker(false);
|
||||
setShowEndTimePicker(false);
|
||||
// 延迟关闭弹窗,确保Toast显示
|
||||
setTimeout(() => {
|
||||
setShowExportPopup(false);
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
console.error("导出失败:", error);
|
||||
const errorMessage = error?.message || "导出失败,请重试";
|
||||
Toast.show({
|
||||
content: error.message || "导出失败,请重试",
|
||||
content: errorMessage,
|
||||
position: "top",
|
||||
duration: 2000,
|
||||
});
|
||||
// 确保loading状态被重置
|
||||
setExportLoading(false);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
@@ -505,13 +692,13 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<div className={style["health-score-info"]}>
|
||||
<div className={style["health-score-status"]}>
|
||||
<span className={style["status-tag"]}>{overviewData?.healthScoreAssessment?.statusTag || "已添加加人"}</span>
|
||||
<span className={style["status-time"]}>最后添加时间: {overviewData?.healthScoreAssessment?.lastAddTime || "18:44:14"}</span>
|
||||
<span className={style["status-time"]}>最后添加时间: {overviewData?.healthScoreAssessment?.lastAddTime || "-"}</span>
|
||||
</div>
|
||||
<div className={style["health-score-display"]}>
|
||||
<div className={style["score-circle-wrapper"]}>
|
||||
<div className={style["score-circle"]}>
|
||||
<div className={style["score-number"]}>
|
||||
{overviewData?.healthScoreAssessment?.score || 67}
|
||||
{overviewData?.healthScoreAssessment?.score || 0}
|
||||
</div>
|
||||
<div className={style["score-label"]}>SCORE</div>
|
||||
</div>
|
||||
@@ -539,7 +726,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<div className={style["stat-icon-up"]}></div>
|
||||
</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{overviewData?.accountValue?.formatted || `¥${overviewData?.accountValue?.value || "29,800"}`}
|
||||
{calculateAccountValue().formatted}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -549,8 +736,8 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<div className={style["stat-title"]}>今日价值变化</div>
|
||||
<div className={style["stat-icon-plus"]}></div>
|
||||
</div>
|
||||
<div className={style["stat-value-positive"]}>
|
||||
{overviewData?.todayValueChange?.formatted || `+${overviewData?.todayValueChange?.change || "500"}`}
|
||||
<div className={calculateTodayValueChange().isPositive ? style["stat-value-positive"] : style["stat-value-negative"]}>
|
||||
{calculateTodayValueChange().formatted}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,7 +810,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<div className={style["score-circle-wrapper"]}>
|
||||
<div className={style["score-circle"]}>
|
||||
<div className={style["score-number"]}>
|
||||
{overviewData?.healthScoreAssessment?.score || 67}
|
||||
{overviewData?.healthScoreAssessment?.score || 0}
|
||||
</div>
|
||||
<div className={style["score-label"]}>SCORE</div>
|
||||
</div>
|
||||
@@ -682,7 +869,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<div className={style["health-item"]} key={`record-${index}`}>
|
||||
<div className={style["health-item-label"]}>
|
||||
<span className={style["health-item-icon-warning"]}></span>
|
||||
{record.title || record.description || "记录"}
|
||||
{record.name || record.description || "记录"}
|
||||
{record.statusTag && (
|
||||
<span className={style["health-item-tag"]}>
|
||||
{record.statusTag}
|
||||
@@ -752,7 +939,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<div className={style["summary-item"]}>
|
||||
<div className={style["summary-label"]}>好友总估值</div>
|
||||
<div className={style["summary-value-highlight"]}>
|
||||
{overviewData?.accountValue?.formatted || "¥1,500,000"}
|
||||
{calculateAccountValue().formatted}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -771,7 +958,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
fetchFriendsList(friendsPage, searchQuery)
|
||||
fetchFriendsList(friendsPage, searchQuery, false)
|
||||
}
|
||||
>
|
||||
重试
|
||||
@@ -789,38 +976,53 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<Avatar src={friend.avatar} />
|
||||
</div>
|
||||
<div className={style["friend-main"]}>
|
||||
<div className={style["friend-name-row"]}>
|
||||
<div className={style["friend-header"]}>
|
||||
<div className={style["friend-name"]}>
|
||||
{friend.nickname || "未知好友"}
|
||||
</div>
|
||||
|
||||
<div className={style["friend-value"]}>
|
||||
<div className={style["value-amount"]}>
|
||||
{friend.valueFormatted
|
||||
|| (typeof friend.value === "number"
|
||||
? `¥${friend.value.toLocaleString()}`
|
||||
: "¥3")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["friend-id-row"]}>
|
||||
ID: {friend.wechatId || "-"}
|
||||
</div>
|
||||
<div className={style["friend-status-row"]}>
|
||||
{friend.statusTags?.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={style["friend-status-chip"]}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{friend.remark && (
|
||||
<span className={style["friend-status-chip"]}>
|
||||
{friend.remark}
|
||||
<div className={style["friend-info"]}>
|
||||
<div className={style["friend-info-item"]}>
|
||||
<span className={style["info-label"]}>微信号:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{friend.wechatId || "-"}
|
||||
</span>
|
||||
</div>
|
||||
{(friend.accountUserName || friend.accountRealName) && (
|
||||
<div className={style["friend-info-item"]}>
|
||||
<span className={style["info-label"]}>归属:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{friend.accountUserName || ""}
|
||||
{friend.accountRealName && `(${friend.accountRealName})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["friend-value"]}>
|
||||
<div className={style["value-amount"]}>
|
||||
{friend.valueFormatted
|
||||
|| (typeof friend.value === "number"
|
||||
? `¥${friend.value.toLocaleString()}`
|
||||
: "估值 -")}
|
||||
</div>
|
||||
{(friend.statusTags?.length > 0 || friend.remark) && (
|
||||
<div className={style["friend-tags"]}>
|
||||
{friend.statusTags?.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={style["friend-tag"]}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{friend.remark && (
|
||||
<span className={style["friend-tag"]}>
|
||||
{friend.remark}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -828,47 +1030,58 @@ const WechatAccountDetail: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页组件 */}
|
||||
{friendsTotal > 20 &&
|
||||
!isFriendsEmpty &&
|
||||
!hasFriendLoadError && (
|
||||
<div className={style["pagination-wrapper"]}>
|
||||
<Pagination
|
||||
total={Math.ceil(friendsTotal / 20)}
|
||||
current={friendsPage}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
{/* 无限滚动加载 */}
|
||||
<InfiniteScroll
|
||||
loadMore={handleLoadMoreFriends}
|
||||
hasMore={friends.length < friendsTotal}
|
||||
threshold={100}
|
||||
>
|
||||
{isFetchingFriends && friends.length > 0 && (
|
||||
<div className={style["friends-loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
||||
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
|
||||
加载中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{friends.length >= friendsTotal && friends.length > 0 && (
|
||||
<div className={style["friends-no-more"]}>
|
||||
<span style={{ color: "#999", fontSize: 12 }}>没有更多了</span>
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
|
||||
<Tabs.Tab title="朋友圈" key="moments">
|
||||
<div className={style["moments-content"]}>
|
||||
{/* 功能按钮栏 */}
|
||||
<div className={style["moments-action-bar"]}>
|
||||
<div className={style["action-button"]}>
|
||||
<span className={style["action-icon-text"]}></span>
|
||||
<span className={style["action-text"]}>文本</span>
|
||||
</div>
|
||||
<div className={style["action-button"]}>
|
||||
<span className={style["action-icon-image"]}></span>
|
||||
<span className={style["action-text"]}>图片</span>
|
||||
</div>
|
||||
<div className={style["action-button"]}>
|
||||
<span className={style["action-icon-video"]}></span>
|
||||
<span className={style["action-text"]}>视频</span>
|
||||
</div>
|
||||
<div
|
||||
className={style["action-button-dark"]}
|
||||
onClick={() => setShowExportPopup(true)}
|
||||
>
|
||||
<span className={style["action-icon-export"]}></span>
|
||||
<span className={style["action-text-light"]}>导出</span>
|
||||
</div>
|
||||
{/* 功能按钮栏 - 移到白色背景上 */}
|
||||
<div className={style["moments-action-bar"]}>
|
||||
<div className={style["action-button"]}>
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div className={style["action-button"]}>
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<div className={style["action-button"]}>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<div
|
||||
className={style["action-button-dark"]}
|
||||
onClick={() => {
|
||||
// 默认设置近7天
|
||||
const today = new Date();
|
||||
const sevenDaysAgo = dayjs(today).subtract(30, "day").toDate();
|
||||
setExportStartTime(sevenDaysAgo);
|
||||
setExportEndTime(today);
|
||||
setShowExportPopup(true);
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["moments-content"]}>
|
||||
{/* 朋友圈列表 */}
|
||||
<div className={style["moments-list"]}>
|
||||
{isFetchingMoments && moments.length === 0 ? (
|
||||
@@ -940,18 +1153,25 @@ const WechatAccountDetail: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{moments.length < momentsTotal && (
|
||||
<div className={style["moments-load-more"]}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleLoadMoreMoments}
|
||||
loading={isFetchingMoments}
|
||||
disabled={isFetchingMoments}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<InfiniteScroll
|
||||
loadMore={handleLoadMoreMoments}
|
||||
hasMore={moments.length < momentsTotal}
|
||||
threshold={100}
|
||||
>
|
||||
{isFetchingMoments && moments.length > 0 && (
|
||||
<div className={style["moments-loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
||||
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
|
||||
加载中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{moments.length >= momentsTotal && moments.length > 0 && (
|
||||
<div className={style["moments-no-more"]}>
|
||||
<span style={{ color: "#999", fontSize: 12 }}>没有更多了</span>
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
@@ -1049,6 +1269,38 @@ const WechatAccountDetail: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 打招呼 */}
|
||||
<div className={style["form-item"]}>
|
||||
<div className={style["form-label"]}>打招呼</div>
|
||||
<div className={style["form-control"]}>
|
||||
<Input.TextArea
|
||||
placeholder="请输入打招呼内容(可选)"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
showCount
|
||||
style={{ resize: "none" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通过后首次消息 */}
|
||||
<div className={style["form-item"]}>
|
||||
<div className={style["form-label"]}>好友通过后的首次消息</div>
|
||||
<div className={style["form-control"]}>
|
||||
<Input.TextArea
|
||||
placeholder="请输入好友通过验证后发送的首条消息(可选)"
|
||||
value={firstMessage}
|
||||
onChange={(e) => setFirstMessage(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
showCount
|
||||
style={{ resize: "none" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["popup-actions"]}>
|
||||
@@ -1068,6 +1320,10 @@ const WechatAccountDetail: React.FC = () => {
|
||||
onClick={() => {
|
||||
setShowTransferConfirm(false);
|
||||
setSelectedDevices([]);
|
||||
// 重置为默认打招呼内容
|
||||
const nickname = accountInfo?.nickname || "未知";
|
||||
setGreeting(`这个是${nickname}的新号,之前那个号没用了,重新加一下您`);
|
||||
setFirstMessage("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
@@ -1079,7 +1335,11 @@ const WechatAccountDetail: React.FC = () => {
|
||||
{/* 朋友圈导出弹窗 */}
|
||||
<Popup
|
||||
visible={showExportPopup}
|
||||
onMaskClick={() => setShowExportPopup(false)}
|
||||
onMaskClick={() => {
|
||||
setShowExportPopup(false);
|
||||
setShowStartTimePicker(false);
|
||||
setShowEndTimePicker(false);
|
||||
}}
|
||||
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
|
||||
>
|
||||
<div className={style["popup-content"]}>
|
||||
@@ -1088,7 +1348,11 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
onClick={() => setShowExportPopup(false)}
|
||||
onClick={() => {
|
||||
setShowExportPopup(false);
|
||||
setShowStartTimePicker(false);
|
||||
setShowEndTimePicker(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
@@ -1162,11 +1426,13 @@ const WechatAccountDetail: React.FC = () => {
|
||||
visible={showStartTimePicker}
|
||||
title="开始时间"
|
||||
value={exportStartTime}
|
||||
max={exportEndTime || new Date()}
|
||||
onClose={() => setShowStartTimePicker(false)}
|
||||
onConfirm={val => {
|
||||
setExportStartTime(val);
|
||||
setShowStartTimePicker(false);
|
||||
}}
|
||||
onCancel={() => setShowStartTimePicker(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1185,6 +1451,8 @@ const WechatAccountDetail: React.FC = () => {
|
||||
visible={showEndTimePicker}
|
||||
title="结束时间"
|
||||
value={exportEndTime}
|
||||
min={exportStartTime || undefined}
|
||||
max={new Date()}
|
||||
onClose={() => setShowEndTimePicker(false)}
|
||||
onConfirm={val => {
|
||||
setExportEndTime(val);
|
||||
@@ -1214,6 +1482,8 @@ const WechatAccountDetail: React.FC = () => {
|
||||
setExportType(undefined);
|
||||
setExportStartTime(null);
|
||||
setExportEndTime(null);
|
||||
setShowStartTimePicker(false);
|
||||
setShowEndTimePicker(false);
|
||||
}}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
@@ -1223,8 +1493,122 @@ const WechatAccountDetail: React.FC = () => {
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
{/* 好友详情弹窗 */}
|
||||
{/* Removed */}
|
||||
{/* 迁移好友弹窗 */}
|
||||
<Popup
|
||||
visible={showTransferFriendPopup}
|
||||
onMaskClick={() => {
|
||||
setShowTransferFriendPopup(false);
|
||||
setSelectedFriend(null);
|
||||
setSelectedKefuAccountId("");
|
||||
setTransferComment("");
|
||||
}}
|
||||
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
|
||||
>
|
||||
<div className={style["popup-content"]}>
|
||||
<div className={style["popup-header"]}>
|
||||
<h3>迁移好友</h3>
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
onClick={() => {
|
||||
setShowTransferFriendPopup(false);
|
||||
setSelectedFriend(null);
|
||||
setSelectedKefuAccountId("");
|
||||
setTransferComment("");
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={style["export-form"]}>
|
||||
{/* 好友信息 */}
|
||||
{selectedFriend && (
|
||||
<div className={style["form-item"]}>
|
||||
<label>好友信息</label>
|
||||
<div className={style["friend-info-card"]}>
|
||||
<Avatar src={selectedFriend.avatar} style={{ width: 40, height: 40 }} />
|
||||
<div className={style["friend-info-text"]}>
|
||||
<div className={style["friend-info-name"]}>
|
||||
{selectedFriend.nickname || "未知好友"}
|
||||
</div>
|
||||
<div className={style["friend-info-id"]}>
|
||||
{selectedFriend.wechatId || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选择客服账号 */}
|
||||
<div className={style["form-item"]}>
|
||||
<label>选择客服账号</label>
|
||||
{loadingKefuAccounts ? (
|
||||
<div className={style["loading-accounts"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
||||
<span style={{ marginLeft: 8 }}>加载中...</span>
|
||||
</div>
|
||||
) : kefuAccounts.length === 0 ? (
|
||||
<div className={style["empty-accounts"]}>暂无客服账号</div>
|
||||
) : (
|
||||
<Select
|
||||
placeholder="请选择客服账号"
|
||||
value={selectedKefuAccountId || undefined}
|
||||
onChange={(value: string) => setSelectedKefuAccountId(value)}
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
>
|
||||
{kefuAccounts.map((account: any) => {
|
||||
const displayName = `${account.userName || ""}(${account.realName || ""})`;
|
||||
return (
|
||||
<Select.Option key={account.id} value={String(account.id)}>
|
||||
{displayName}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
<div className={style["form-item"]}>
|
||||
<label>备注(可选)</label>
|
||||
<Input
|
||||
placeholder="请输入备注信息"
|
||||
value={transferComment}
|
||||
onChange={e => setTransferComment(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["popup-footer"]}>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={handleConfirmTransferFriend}
|
||||
loading={transferFriendLoading}
|
||||
disabled={transferFriendLoading || !selectedKefuAccountId}
|
||||
>
|
||||
{transferFriendLoading ? "转移中..." : "确认转移"}
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
color="danger"
|
||||
fill="outline"
|
||||
onClick={() => {
|
||||
setShowTransferFriendPopup(false);
|
||||
setSelectedFriend(null);
|
||||
setSelectedKefuAccountId("");
|
||||
setTransferComment("");
|
||||
}}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
|
||||
export function getPlanList(params: {
|
||||
sceneId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
limit: number;
|
||||
}): Promise<PlanListResponse> {
|
||||
return request(`/v1/plan/list`, params, "GET");
|
||||
}
|
||||
@@ -36,6 +36,13 @@ export function getUserList(planId: string, type: number) {
|
||||
}
|
||||
|
||||
//获客列表
|
||||
export function getFriendRequestTaskStats(taskId: string) {
|
||||
return request(`/v1/dashboard/friendRequestTaskStats`, { taskId }, "GET");
|
||||
export function getFriendRequestTaskStats(
|
||||
taskId: string,
|
||||
params?: { startTime?: string; endTime?: string },
|
||||
) {
|
||||
return request(
|
||||
`/v1/dashboard/friendRequestTaskStats`,
|
||||
{ taskId, ...params },
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
<div className={style.accountName}>
|
||||
{account.userinfo.nickname ||
|
||||
account.userinfo.alias ||
|
||||
`账号${account.id}`}
|
||||
`账号${account.phone || account.id}`}
|
||||
</div>
|
||||
<div className={style.accountWechatId}>
|
||||
{account.userinfo.wechatId || "未绑定微信号"}
|
||||
|
||||
@@ -137,7 +137,7 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
<div className={style.accountName}>
|
||||
{account.userinfo.nickname ||
|
||||
account.userinfo.alias ||
|
||||
`账号${account.id}`}
|
||||
`账号${account.phone || account.id}`}
|
||||
</div>
|
||||
<div className={style.accountWechatId}>
|
||||
{account.userinfo.wechatId || "未绑定微信号"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, SpinLoading } from "antd-mobile";
|
||||
import { Button, message } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Popup, SpinLoading, DatePicker } from "antd-mobile";
|
||||
import { Button, message, Input } from "antd";
|
||||
import { CloseOutlined, CalendarOutlined } from "@ant-design/icons";
|
||||
import style from "./Popups.module.scss";
|
||||
import { getFriendRequestTaskStats } from "../api";
|
||||
import LineChart2 from "@/components/LineChart2";
|
||||
@@ -39,12 +39,52 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
|
||||
const [xData, setXData] = useState<any[]>([]);
|
||||
const [yData, setYData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [startTime, setStartTime] = useState<Date | null>(null);
|
||||
const [endTime, setEndTime] = useState<Date | null>(null);
|
||||
const [showStartTimePicker, setShowStartTimePicker] = useState(false);
|
||||
const [showEndTimePicker, setShowEndTimePicker] = useState(false);
|
||||
|
||||
// 当弹窗打开且有ruleId时,获取数据
|
||||
// 格式化日期为 YYYY-MM-DD
|
||||
const formatDate = useCallback((date: Date | null): string => {
|
||||
if (!date) return "";
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}, []);
|
||||
|
||||
// 初始化默认时间(近7天)
|
||||
useEffect(() => {
|
||||
if (visible && ruleId) {
|
||||
if (visible) {
|
||||
// 如果时间未设置,设置默认值为近7天
|
||||
if (!startTime || !endTime) {
|
||||
const today = new Date();
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(today.getDate() - 7);
|
||||
setStartTime(sevenDaysAgo);
|
||||
setEndTime(today);
|
||||
}
|
||||
} else {
|
||||
// 弹窗关闭时重置时间,下次打开时重新初始化
|
||||
setStartTime(null);
|
||||
setEndTime(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 当弹窗打开或有ruleId或时间筛选变化时,获取数据
|
||||
useEffect(() => {
|
||||
if (!visible || !ruleId) return;
|
||||
|
||||
setLoading(true);
|
||||
getFriendRequestTaskStats(ruleId.toString())
|
||||
const params: { startTime?: string; endTime?: string } = {};
|
||||
if (startTime) {
|
||||
params.startTime = formatDate(startTime);
|
||||
}
|
||||
if (endTime) {
|
||||
params.endTime = formatDate(endTime);
|
||||
}
|
||||
|
||||
getFriendRequestTaskStats(ruleId.toString(), params)
|
||||
.then(res => {
|
||||
console.log(res);
|
||||
setXData(res.dateArray);
|
||||
@@ -57,13 +97,15 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
|
||||
res.successRateArray,
|
||||
]);
|
||||
setStatistics(res.totalStats);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("获取统计数据失败:", error);
|
||||
message.error("获取统计数据失败");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [visible, ruleId]);
|
||||
}, [visible, ruleId, startTime, endTime, formatDate]);
|
||||
|
||||
const title = ruleName ? `${ruleName} - 累计统计数据` : "累计统计数据";
|
||||
return (
|
||||
@@ -89,6 +131,55 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 时间筛选 */}
|
||||
<div className={style.dateFilter}>
|
||||
<div className={style.dateFilterItem}>
|
||||
<label className={style.dateFilterLabel}>开始时间</label>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="请选择开始时间"
|
||||
value={startTime ? formatDate(startTime) : ""}
|
||||
onClick={() => setShowStartTimePicker(true)}
|
||||
prefix={<CalendarOutlined />}
|
||||
className={style.dateFilterInput}
|
||||
/>
|
||||
<DatePicker
|
||||
visible={showStartTimePicker}
|
||||
title="开始时间"
|
||||
value={startTime}
|
||||
max={endTime || new Date()}
|
||||
onClose={() => setShowStartTimePicker(false)}
|
||||
onConfirm={val => {
|
||||
setStartTime(val);
|
||||
setShowStartTimePicker(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.dateFilterItem}>
|
||||
<label className={style.dateFilterLabel}>结束时间</label>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="请选择结束时间"
|
||||
value={endTime ? formatDate(endTime) : ""}
|
||||
onClick={() => setShowEndTimePicker(true)}
|
||||
prefix={<CalendarOutlined />}
|
||||
className={style.dateFilterInput}
|
||||
/>
|
||||
<DatePicker
|
||||
visible={showEndTimePicker}
|
||||
title="结束时间"
|
||||
value={endTime}
|
||||
min={startTime || undefined}
|
||||
max={new Date()}
|
||||
onClose={() => setShowEndTimePicker(false)}
|
||||
onConfirm={val => {
|
||||
setEndTime(val);
|
||||
setShowEndTimePicker(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据表格 */}
|
||||
<div className={style.statisticsContent}>
|
||||
{loading ? (
|
||||
|
||||
@@ -663,6 +663,32 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// 日期筛选样式
|
||||
.dateFilter {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dateFilterItem {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dateFilterLabel {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dateFilterInput {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 统计数据弹窗样式
|
||||
.statisticsContent {
|
||||
flex: 1;
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Task {
|
||||
total_customers?: number;
|
||||
today_customers?: number;
|
||||
lastUpdated?: string;
|
||||
planType?: number; // 0-全局计划, 1-独立计划
|
||||
stats?: {
|
||||
devices?: number;
|
||||
acquired?: number;
|
||||
|
||||
@@ -2,6 +2,73 @@
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #2563eb;
|
||||
font-size: 20px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.section-dot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #2563eb;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 8px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
.section-title-independent {
|
||||
.section-dot {
|
||||
background: #fb923c;
|
||||
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.section-dot-independent {
|
||||
background: #fb923c;
|
||||
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
|
||||
.plan-list-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
Popup,
|
||||
Card,
|
||||
Tag,
|
||||
InfiniteScroll,
|
||||
} from "antd-mobile";
|
||||
import { Input, Pagination } from "antd";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
CopyOutlined,
|
||||
@@ -80,7 +81,7 @@ const ScenarioList: React.FC = () => {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
const limit = 20;
|
||||
|
||||
// 获取计划列表数据
|
||||
const fetchPlanList = async (page: number, isLoadMore: boolean = false) => {
|
||||
@@ -96,21 +97,27 @@ const ScenarioList: React.FC = () => {
|
||||
const response = await getPlanList({
|
||||
sceneId: scenarioId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
limit: limit,
|
||||
});
|
||||
|
||||
if (response && response.list) {
|
||||
// 处理 planType 字段
|
||||
const processedList = response.list.map((task: any) => ({
|
||||
...task,
|
||||
planType: task.planType ?? task.config?.planType ?? 1, // 默认独立计划
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
// 加载更多时,追加数据
|
||||
setTasks(prev => [...prev, ...response.list]);
|
||||
setTasks(prev => [...prev, ...processedList]);
|
||||
} else {
|
||||
// 首次加载或刷新时,替换数据
|
||||
setTasks(response.list);
|
||||
setTasks(processedList);
|
||||
}
|
||||
|
||||
// 更新分页信息
|
||||
setTotal(response.total || 0);
|
||||
setHasMore(response.list.length === pageSize);
|
||||
setHasMore(processedList.length === limit);
|
||||
setCurrentPage(page);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -149,10 +156,11 @@ const ScenarioList: React.FC = () => {
|
||||
fetchScenarioData();
|
||||
}, [scenarioId]);
|
||||
|
||||
// 分页改变处理
|
||||
const handlePageChange = async (page: number) => {
|
||||
setCurrentPage(page);
|
||||
await fetchPlanList(page, false);
|
||||
// 加载更多
|
||||
const handleLoadMore = async () => {
|
||||
if (!hasMore || loadingMore || loadingTasks) return;
|
||||
const nextPage = currentPage + 1;
|
||||
await fetchPlanList(nextPage, true);
|
||||
};
|
||||
|
||||
const handleCopyPlan = async (taskId: string) => {
|
||||
@@ -311,6 +319,10 @@ const ScenarioList: React.FC = () => {
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 分隔全局计划和独立计划
|
||||
const globalPlans = filteredTasks.filter(task => task.planType === 0);
|
||||
const independentPlans = filteredTasks.filter(task => task.planType === 1 || !task.planType);
|
||||
|
||||
// 生成操作菜单
|
||||
const getActionMenu = (task: Task) => [
|
||||
{
|
||||
@@ -370,13 +382,13 @@ const ScenarioList: React.FC = () => {
|
||||
title={scenarioName || ""}
|
||||
right={
|
||||
scenarioId !== "10" ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
@@ -405,20 +417,18 @@ const ScenarioList: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
footer={
|
||||
<div className="pagination-container">
|
||||
<Pagination
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
current={currentPage}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper={false}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style["scenario-list-page"]}>
|
||||
{/* 全局计划提示 */}
|
||||
{globalPlans.length > 0 && (
|
||||
<div className={style["info-box"]}>
|
||||
<span className={style["info-icon"]}>ℹ</span>
|
||||
<p className={style["info-text"]}>
|
||||
全局获客计划将应用于所有设备,包含新添加的设备,请确保设置合理的规则。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 计划列表 */}
|
||||
<div className={style["plan-list"]}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
@@ -427,18 +437,130 @@ const ScenarioList: React.FC = () => {
|
||||
{searchTerm ? "没有找到匹配的计划" : "暂无计划"}
|
||||
</div>
|
||||
{scenarioId !== "10" && (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
className={style["create-first-btn"]}
|
||||
>
|
||||
<PlusOutlined /> 创建第一个计划
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
className={style["create-first-btn"]}
|
||||
>
|
||||
<PlusOutlined /> 创建第一个计划
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTasks.map(task => (
|
||||
{/* 全局获客计划 */}
|
||||
{globalPlans.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h2 className={style["section-title"]}>
|
||||
<div className={style["section-dot"]}></div>
|
||||
全局获客计划
|
||||
</h2>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{globalPlans.map(task => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
<div className={style["plan-name"]}>{task.name}</div>
|
||||
<div className={style["plan-header-right"]}>
|
||||
<Tag color={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</Tag>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
className={style["more-btn"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
setShowActionMenu(task.id);
|
||||
}}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据网格 */}
|
||||
<div className={style["stats-grid"]}>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowDeviceList(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>设备数</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{deviceCount(task)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowAccountList(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>已获客</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task?.acquiredCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowOreadyAdd(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>已添加</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.passCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowPoolList(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>通过率</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.passRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:上次执行时间 */}
|
||||
<div className={style["plan-footer"]}>
|
||||
<div className={style["last-execution"]}>
|
||||
<ClockCircleOutlined />
|
||||
<span>上次执行: {task.lastUpdated || "--"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<QrcodeOutlined
|
||||
onClick={() => {
|
||||
setShowActionMenu(null);
|
||||
handleShowQrCode(task.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 独立获客计划 */}
|
||||
{independentPlans.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h2 className={`${style["section-title"]} ${style["section-title-independent"]}`}>
|
||||
<div className={`${style["section-dot"]} ${style["section-dot-independent"]}`}></div>
|
||||
独立获客计划
|
||||
</h2>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{independentPlans.map(task => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
@@ -530,6 +652,37 @@ const ScenarioList: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 上拉加载更多 */}
|
||||
<InfiniteScroll
|
||||
loadMore={handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
threshold={100}
|
||||
>
|
||||
{loadingMore && (
|
||||
<div style={{ padding: "20px", textAlign: "center" }}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
||||
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
|
||||
加载中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && filteredTasks.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
textAlign: "center",
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
没有更多了
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -594,43 +747,52 @@ const ScenarioList: React.FC = () => {
|
||||
<SpinLoading color="primary" />
|
||||
<div>生成二维码中...</div>
|
||||
</div>
|
||||
) : qrImg ? (
|
||||
<>
|
||||
<img
|
||||
src={qrImg}
|
||||
alt="小程序二维码"
|
||||
className={style["qr-image"]}
|
||||
/>
|
||||
{/* 链接复制区域 */}
|
||||
<div className={style["qr-link-section"]}>
|
||||
<div className={style["link-label"]}>小程序链接</div>
|
||||
<div className={style["link-input-wrapper"]}>
|
||||
<Input
|
||||
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`}
|
||||
readOnly
|
||||
className={style["link-input"]}
|
||||
placeholder="小程序链接"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
Toast.show({
|
||||
content: "链接已复制到剪贴板",
|
||||
position: "top",
|
||||
});
|
||||
}}
|
||||
className={style["copy-button"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={style["qr-error"]}>二维码生成失败</div>
|
||||
<>
|
||||
{/* 二维码显示区域 */}
|
||||
{qrImg ? (
|
||||
<img
|
||||
src={qrImg}
|
||||
alt="小程序二维码"
|
||||
className={style["qr-image"]}
|
||||
/>
|
||||
) : (
|
||||
<div className={style["qr-error"]}>
|
||||
<QrcodeOutlined style={{ fontSize: 48, color: "#999", marginBottom: 12 }} />
|
||||
<div>二维码生成失败</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* H5链接展示 - 无论二维码是否成功都要显示 */}
|
||||
{currentTaskId && (
|
||||
<div className={style["qr-link-section"]}>
|
||||
<div className={style["link-label"]}>H5链接</div>
|
||||
<div className={style["link-input-wrapper"]}>
|
||||
<Input
|
||||
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`}
|
||||
readOnly
|
||||
className={style["link-input"]}
|
||||
placeholder="H5链接"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
Toast.show({
|
||||
content: "链接已复制到剪贴板",
|
||||
position: "top",
|
||||
});
|
||||
}}
|
||||
className={style["copy-button"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,24 +70,72 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
|
||||
// 处理webhook URL,确保包含完整的API地址
|
||||
const fullWebhookUrl = useMemo(() => {
|
||||
return buildApiUrl(webhookUrl);
|
||||
return buildApiUrl('');
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 生成测试URL
|
||||
// 快速测试使用的 GET 地址(携带示例查询参数,方便在浏览器中直接访问)
|
||||
const testUrl = useMemo(() => {
|
||||
if (!fullWebhookUrl) return "";
|
||||
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
|
||||
}, [fullWebhookUrl]);
|
||||
return buildApiUrl(webhookUrl);
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const handleCopy = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
// 先尝试使用 Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 回退到传统的 textarea 复制方式
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
} catch {
|
||||
Toast.show({
|
||||
content: `${type}复制失败,请手动复制`,
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
} else {
|
||||
// 不支持 Clipboard API 时直接使用回退方案
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
} catch {
|
||||
Toast.show({
|
||||
content: `${type}复制失败,请手动复制`,
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestInBrowser = () => {
|
||||
@@ -96,7 +144,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className={style["config-content"]}>
|
||||
{/* API密钥配置 */}
|
||||
{/* 鉴权参数配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
@@ -122,7 +170,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
{/* 接口地址与参数说明 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
@@ -150,27 +198,42 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
{/* 参数说明 */}
|
||||
<div className={style["params-grid"]}>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>必要参数</h4>
|
||||
<h4>鉴权参数(必填)</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
<code>apiKey</code> - 分配给第三方的接口密钥(每个任务唯一)
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号码
|
||||
<code>sign</code> - 签名值,按文档的签名规则生成
|
||||
</div>
|
||||
<div>
|
||||
<code>timestamp</code> - 秒级时间戳(与服务器时间差不超过 5 分钟)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>可选参数</h4>
|
||||
<h4>业务参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>source</code> - 来源标识
|
||||
<code>wechatId</code> - 微信号,存在时优先作为主标识
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号,当 <code>wechatId</code> 为空时用作主标识
|
||||
</div>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code>source</code> - 线索来源描述,如“百度推广”、“抖音直播间”
|
||||
</div>
|
||||
<div>
|
||||
<code>remark</code> - 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code>tags</code> - 客户标签
|
||||
<code>tags</code> - 微信标签,逗号分隔,如 <code>"高意向,电商,女装"</code>
|
||||
</div>
|
||||
<div>
|
||||
<code>siteTags</code> - 站内标签,逗号分隔,用于站内进一步细分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,93 +242,131 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQuickTestTab = () => (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试URL</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
const renderQuickTestTab = () => {
|
||||
return (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试 URL(GET 示例)</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderDocsTab = () => (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>完整API文档</h4>
|
||||
<p>详细的接口说明和参数文档</p>
|
||||
</Card>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<LinkOutlined />
|
||||
</div>
|
||||
<h4>集成指南</h4>
|
||||
<p>第三方平台集成教程</p>
|
||||
</Card>
|
||||
const renderDocsTab = () => {
|
||||
const docUrl = `${import.meta.env.VITE_API_BASE_URL}/doc/api_v1.md`;
|
||||
|
||||
return (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>对外获客线索上报接口文档(V1)</h4>
|
||||
<p>点击下方按钮可直接在浏览器中打开最新的远程文档。</p>
|
||||
<div className={style["doc-actions"]}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
window.open(docUrl, "_blank");
|
||||
}}
|
||||
className={style["doc-open-btn"]}
|
||||
>
|
||||
在浏览器中打开文档
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(docUrl, "文档链接")}
|
||||
className={style["doc-copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制文档链接
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderCodeTab = () => {
|
||||
const codeExamples = {
|
||||
javascript: `fetch('${fullWebhookUrl}', {
|
||||
javascript: `// 参考 api_v1 文档示例,使用 JSON 方式 POST
|
||||
fetch('${fullWebhookUrl}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: '张三',
|
||||
apiKey: '${apiKey}',
|
||||
timestamp: 1710000000, // 秒级时间戳
|
||||
phone: '13800138000',
|
||||
name: '张三',
|
||||
source: '官网表单',
|
||||
remark: '通过H5表单提交',
|
||||
tags: '高意向,电商',
|
||||
siteTags: '新客,女装',
|
||||
// sign 需要根据签名规则生成
|
||||
sign: '根据签名规则生成的MD5字符串'
|
||||
})
|
||||
})`,
|
||||
python: `import requests
|
||||
|
||||
url = '${fullWebhookUrl}'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
data = {
|
||||
'name': '张三',
|
||||
'phone': '13800138000',
|
||||
'source': '官网表单'
|
||||
"apiKey": "${apiKey}",
|
||||
"timestamp": 1710000000,
|
||||
"phone": "13800138000",
|
||||
"name": "张三",
|
||||
"source": "官网表单",
|
||||
"remark": "通过H5表单提交",
|
||||
"tags": "高意向,电商",
|
||||
"siteTags": "新客,女装",
|
||||
# sign 需要根据签名规则生成
|
||||
"sign": "根据签名规则生成的MD5字符串"
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)`,
|
||||
php: `<?php
|
||||
$url = '${fullWebhookUrl}';
|
||||
$data = array(
|
||||
'name' => '张三',
|
||||
'apiKey' => '${apiKey}',
|
||||
'timestamp' => 1710000000,
|
||||
'phone' => '13800138000',
|
||||
'source' => '官网表单'
|
||||
'name' => '张三',
|
||||
'source' => '官网表单',
|
||||
'remark' => '通过H5表单提交',
|
||||
'tags' => '高意向,电商',
|
||||
'siteTags' => '新客,女装',
|
||||
// sign 需要根据签名规则生成
|
||||
'sign' => '根据签名规则生成的MD5字符串'
|
||||
);
|
||||
|
||||
$options = array(
|
||||
'http' => array(
|
||||
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
|
||||
'header' => "Content-type: application/json\\r\\n",
|
||||
'method' => 'POST',
|
||||
'content' => json_encode($data)
|
||||
)
|
||||
@@ -279,12 +380,11 @@ import java.net.http.HttpResponse;
|
||||
import java.net.URI;
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
|
||||
String json = "{\\"apiKey\\":\\"${apiKey}\\",\\"timestamp\\":1710000000,\\"phone\\":\\"13800138000\\",\\"name\\":\\"张三\\",\\"source\\":\\"官网表单\\",\\"remark\\":\\"通过H5表单提交\\",\\"tags\\":\\"高意向,电商\\",\\"siteTags\\":\\"新客,女装\\",\\"sign\\":\\"根据签名规则生成的MD5字符串\\"}";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("${fullWebhookUrl}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer ${apiKey}")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
|
||||
@@ -394,11 +494,7 @@ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.o
|
||||
<SafetyOutlined />
|
||||
所有数据传输均采用HTTPS加密
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
className={style["complete-btn"]}
|
||||
>
|
||||
<Button color="primary" onClick={onClose} className={style["complete-btn"]}>
|
||||
完成配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
// 步骤定义 - 三个步骤
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
export const steps = [
|
||||
@@ -30,6 +30,26 @@ export interface FormData {
|
||||
wechatGroups: string[];
|
||||
wechatGroupsOptions: GroupSelectionItem[];
|
||||
messagePlans: any[];
|
||||
// 拉群设置
|
||||
groupInviteEnabled?: boolean;
|
||||
groupName?: string;
|
||||
fixedGroupMembers?: any[]; // 固定群成员,使用好友选择组件结构
|
||||
// 分销相关
|
||||
distributionEnabled?: boolean;
|
||||
// 选中的分销渠道ID列表(前端使用,提交时转为 distributionChannels)
|
||||
distributionChannelIds?: Array<string | number>;
|
||||
// 选中的分销渠道选项(用于回显名称)
|
||||
distributionChannelsOptions?: Array<{
|
||||
id: string | number;
|
||||
name: string;
|
||||
code?: string;
|
||||
}>;
|
||||
// 获客奖励金额(元,前端使用,提交时转为 customerRewardAmount)
|
||||
distributionCustomerReward?: number;
|
||||
// 添加奖励金额(元,前端使用,提交时转为 addFriendRewardAmount)
|
||||
distributionAddReward?: number;
|
||||
// 计划类型:0-全局计划,1-独立计划(仅管理员可创建)
|
||||
planType?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
export const defFormData: FormData = {
|
||||
@@ -45,6 +65,7 @@ export const defFormData: FormData = {
|
||||
enabled: true,
|
||||
remarkFormat: "",
|
||||
addFriendInterval: 1,
|
||||
tips: "请注意消息,稍后加你微信",
|
||||
posters: [],
|
||||
device: [],
|
||||
customTags: [],
|
||||
@@ -56,4 +77,13 @@ export const defFormData: FormData = {
|
||||
wechatGroupsOptions: [],
|
||||
contentGroups: [],
|
||||
contentGroupsOptions: [],
|
||||
groupInviteEnabled: false,
|
||||
groupName: "",
|
||||
fixedGroupMembers: [],
|
||||
distributionEnabled: false,
|
||||
distributionChannelIds: [],
|
||||
distributionChannelsOptions: [],
|
||||
distributionCustomerReward: undefined,
|
||||
distributionAddReward: undefined,
|
||||
planType: 1, // 默认独立计划
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
updatePlan,
|
||||
} from "./index.api";
|
||||
import { FormData, defFormData, steps } from "./index.data";
|
||||
import { fetchChannelList } from "@/pages/mobile/workspace/distribution-management/api";
|
||||
|
||||
export default function NewPlan() {
|
||||
const router = useNavigate();
|
||||
@@ -49,10 +50,46 @@ export default function NewPlan() {
|
||||
//获取计划详情
|
||||
|
||||
const detail = await getPlanDetail(planId);
|
||||
|
||||
// 处理分销相关数据回填
|
||||
const distributionChannels = detail.distributionChannels || [];
|
||||
let distributionChannelsOptions: Array<{ id: string | number; name: string; code?: string }> = [];
|
||||
|
||||
if (distributionChannels.length > 0) {
|
||||
// 判断 distributionChannels 是对象数组还是ID数组
|
||||
const isObjectArray = distributionChannels.some((item: any) => typeof item === 'object' && item !== null);
|
||||
|
||||
if (isObjectArray) {
|
||||
// 如果已经是对象数组,直接使用(包含 id, code, name)
|
||||
distributionChannelsOptions = distributionChannels.map((channel: any) => ({
|
||||
id: channel.id,
|
||||
name: channel.name || `渠道${channel.id}`,
|
||||
code: channel.code,
|
||||
}));
|
||||
} else {
|
||||
// 如果是ID数组,需要查询渠道信息
|
||||
try {
|
||||
const channelRes = await fetchChannelList({ page: 1, limit: 200, status: "enabled" });
|
||||
distributionChannelsOptions = distributionChannels.map((channelId: number) => {
|
||||
const channel = channelRes.list.find((c: any) => c.id === channelId);
|
||||
return channel
|
||||
? { id: channelId, name: channel.name, code: channel.code }
|
||||
: { id: channelId, name: `渠道${channelId}` };
|
||||
});
|
||||
} catch {
|
||||
// 如果获取渠道信息失败,使用默认名称
|
||||
distributionChannelsOptions = distributionChannels.map((channelId: number) => ({
|
||||
id: channelId,
|
||||
name: `渠道${channelId}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: detail.name ?? "",
|
||||
scenario: Number(detail.scenario) || 1,
|
||||
scenario: Number(detail.sceneId || detail.scenario) || 1,
|
||||
scenarioTags: detail.scenarioTags ?? [],
|
||||
customTags: detail.customTags ?? [],
|
||||
customTagsOptions: detail.customTags ?? [],
|
||||
@@ -64,7 +101,7 @@ export default function NewPlan() {
|
||||
startTime: detail.startTime ?? "09:00",
|
||||
endTime: detail.endTime ?? "18:00",
|
||||
enabled: detail.enabled ?? true,
|
||||
sceneId: Number(detail.scenario) || 1,
|
||||
sceneId: Number(detail.sceneId || detail.scenario) || 1,
|
||||
remarkFormat: detail.remarkFormat ?? "",
|
||||
addFriendInterval: detail.addFriendInterval ?? 1,
|
||||
tips: detail.tips ?? "",
|
||||
@@ -74,14 +111,40 @@ export default function NewPlan() {
|
||||
wechatGroupsOptions: detail.wechatGroupsOptions ?? [],
|
||||
contentGroups: detail.contentGroups ?? [],
|
||||
contentGroupsOptions: detail.contentGroupsOptions ?? [],
|
||||
// 拉群设置
|
||||
groupInviteEnabled: detail.groupInviteEnabled ?? false,
|
||||
groupName: detail.groupName ?? "",
|
||||
// 计划类型
|
||||
planType: detail.planType ?? 1,
|
||||
// 优先使用后端返回的 options(完整好友信息),否则退回到 ID 数组或旧字段
|
||||
fixedGroupMembers:
|
||||
detail.groupFixedMembersOptions ??
|
||||
detail.fixedGroupMembers ??
|
||||
detail.groupFixedMembers ??
|
||||
[],
|
||||
status: detail.status ?? 0,
|
||||
messagePlans: detail.messagePlans ?? [],
|
||||
// 分销相关数据回填
|
||||
distributionEnabled: detail.distributionEnabled ?? false,
|
||||
distributionChannelIds: distributionChannelsOptions.map(item => item.id),
|
||||
distributionChannelsOptions: distributionChannelsOptions,
|
||||
distributionCustomerReward: detail.customerRewardAmount,
|
||||
distributionAddReward: detail.addFriendRewardAmount,
|
||||
}));
|
||||
} else {
|
||||
// 新建时,如果是海报场景,设置默认获客成功提示
|
||||
const defaultTips = "请注意消息,稍后加你微信";
|
||||
if (scenarioId) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
tips: Number(scenarioId) === 1 ? defaultTips : prev.tips || "",
|
||||
}));
|
||||
} else {
|
||||
// 如果没有 scenarioId,默认是海报场景(scenario === 1),设置默认提示
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tips: prev.scenario === 1 ? defaultTips : prev.tips || "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -118,21 +181,60 @@ export default function NewPlan() {
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 构建提交数据,转换分销相关字段为接口需要的格式
|
||||
const submitData: any = {
|
||||
...formData,
|
||||
sceneId: Number(formData.scenario),
|
||||
};
|
||||
|
||||
// 转换分销相关字段为接口需要的格式
|
||||
if (formData.distributionEnabled) {
|
||||
submitData.distributionEnabled = true;
|
||||
// 转换渠道ID数组,确保都是数字类型
|
||||
submitData.distributionChannels = (formData.distributionChannelIds || []).map(id =>
|
||||
typeof id === 'string' ? Number(id) : id
|
||||
);
|
||||
// 转换奖励金额,确保是浮点数,最多2位小数
|
||||
submitData.customerRewardAmount = formData.distributionCustomerReward
|
||||
? Number(Number(formData.distributionCustomerReward).toFixed(2))
|
||||
: 0;
|
||||
submitData.addFriendRewardAmount = formData.distributionAddReward
|
||||
? Number(Number(formData.distributionAddReward).toFixed(2))
|
||||
: 0;
|
||||
} else {
|
||||
// 如果未开启分销,设置为false
|
||||
submitData.distributionEnabled = false;
|
||||
}
|
||||
|
||||
// 拉群设置字段转换
|
||||
if (formData.groupInviteEnabled) {
|
||||
submitData.groupInviteEnabled = true;
|
||||
submitData.groupName = formData.groupName || "";
|
||||
// 后端期望的字段:groupFixedMembers,使用好友ID数组
|
||||
submitData.groupFixedMembers = (formData.fixedGroupMembers || []).map(
|
||||
(f: any) => f.id,
|
||||
);
|
||||
} else {
|
||||
submitData.groupInviteEnabled = false;
|
||||
submitData.groupName = "";
|
||||
submitData.groupFixedMembers = [];
|
||||
}
|
||||
|
||||
// 移除前端使用的字段,避免提交到后端
|
||||
delete submitData.distributionChannelIds;
|
||||
delete submitData.distributionChannelsOptions;
|
||||
delete submitData.distributionCustomerReward;
|
||||
delete submitData.distributionAddReward;
|
||||
delete submitData.fixedGroupMembers;
|
||||
|
||||
if (isEdit && planId) {
|
||||
// 编辑:拼接后端需要的完整参数
|
||||
const editData = {
|
||||
...formData,
|
||||
...{ sceneId: Number(formData.scenario) },
|
||||
id: Number(planId),
|
||||
planId: Number(planId),
|
||||
// 兼容后端需要的字段
|
||||
// 你可以根据实际需要补充其它字段
|
||||
};
|
||||
await updatePlan(editData);
|
||||
submitData.id = Number(planId);
|
||||
submitData.planId = Number(planId);
|
||||
await updatePlan(submitData);
|
||||
} else {
|
||||
// 新建
|
||||
formData.sceneId = Number(formData.scenario);
|
||||
await createPlan(formData);
|
||||
await createPlan(submitData);
|
||||
}
|
||||
message.success(isEdit ? "计划已更新" : "获客计划已创建");
|
||||
const sceneItem = sceneList.find(v => formData.scenario === v.id);
|
||||
@@ -180,6 +282,7 @@ export default function NewPlan() {
|
||||
onChange={onChange}
|
||||
sceneList={sceneList}
|
||||
sceneLoading={sceneLoading}
|
||||
planId={planId}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,817 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Input, Button, Switch } from "antd";
|
||||
import { Toast, SpinLoading } from "antd-mobile";
|
||||
import {
|
||||
SearchOutlined,
|
||||
DeleteOutlined,
|
||||
QrcodeOutlined,
|
||||
CopyOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import styles from "./base.module.scss";
|
||||
import { fetchChannelList } from "@/pages/mobile/workspace/distribution-management/api";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import request from "@/api/request";
|
||||
|
||||
interface DistributionSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
planId?: string; // 计划ID,用于生成二维码
|
||||
}
|
||||
|
||||
const DistributionSettings: React.FC<DistributionSettingsProps> = ({
|
||||
formData,
|
||||
onChange,
|
||||
planId,
|
||||
}) => {
|
||||
// 分销相关状态
|
||||
const [distributionEnabled, setDistributionEnabled] = useState<boolean>(
|
||||
formData.distributionEnabled ?? false,
|
||||
);
|
||||
const [channelModalVisible, setChannelModalVisible] = useState(false);
|
||||
const [channelLoading, setChannelLoading] = useState(false);
|
||||
const [channelList, setChannelList] = useState<any[]>([]);
|
||||
const [tempSelectedChannelIds, setTempSelectedChannelIds] = useState<
|
||||
Array<string | number>
|
||||
>(formData.distributionChannelIds || []);
|
||||
const [channelSearchQuery, setChannelSearchQuery] = useState("");
|
||||
const [channelCurrentPage, setChannelCurrentPage] = useState(1);
|
||||
const [channelTotal, setChannelTotal] = useState(0);
|
||||
const [customerReward, setCustomerReward] = useState<number | undefined>(
|
||||
formData.distributionCustomerReward
|
||||
);
|
||||
const [addReward, setAddReward] = useState<number | undefined>(
|
||||
formData.distributionAddReward
|
||||
);
|
||||
|
||||
// 二维码相关状态
|
||||
const [qrCodeMap, setQrCodeMap] = useState<Record<string | number, {
|
||||
qrCode: string;
|
||||
url: string;
|
||||
loading: boolean;
|
||||
}>>({});
|
||||
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||
const [currentQrChannel, setCurrentQrChannel] = useState<{
|
||||
id: string | number;
|
||||
name: string;
|
||||
code?: string;
|
||||
} | null>(null);
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// 生成H5链接(独立生成,不依赖二维码)
|
||||
const generateH5Url = (channelId: string | number, channelCode?: string): string => {
|
||||
// 生成H5链接,参考列表中的实现
|
||||
// 格式: https://h5.ckb.quwanzhi.com/#/pages/form/input2?id={planId}&channelId={channelId}
|
||||
if (planId) {
|
||||
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${planId}&channelId=${channelId}`;
|
||||
} else if (channelCode) {
|
||||
// 新建状态,使用渠道code
|
||||
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?channelCode=${channelCode}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// 生成渠道二维码
|
||||
const generateChannelQRCode = async (channelId: string | number, channelCode?: string) => {
|
||||
// 先生成H5链接(无论二维码是否成功都要显示)
|
||||
const h5Url = generateH5Url(channelId, channelCode);
|
||||
|
||||
// 如果已经有二维码数据,只更新H5链接(如果还没有)
|
||||
if (qrCodeMap[channelId]) {
|
||||
if (!qrCodeMap[channelId].url) {
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: { ...prev[channelId], url: h5Url },
|
||||
}));
|
||||
}
|
||||
// 如果已经有二维码,直接返回
|
||||
if (qrCodeMap[channelId].qrCode) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 初始化,先设置H5链接
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: {
|
||||
qrCode: "",
|
||||
url: h5Url,
|
||||
loading: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// 设置加载状态
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: { ...prev[channelId], loading: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
// 参考列表生成的参数,使用计划ID和渠道ID/code生成二维码
|
||||
// 如果是在新建状态(没有planId),使用渠道code;如果有planId,使用planId和channelId
|
||||
const params: any = {};
|
||||
if (planId) {
|
||||
params.taskId = planId;
|
||||
params.channelId = channelId;
|
||||
} else if (channelCode) {
|
||||
params.channelCode = channelCode;
|
||||
}
|
||||
|
||||
// 调用API生成二维码,参考列表中的实现
|
||||
// 接口返回格式: { code: 200, msg: "获取小程序码成功", data: "data:image/png;base64,..." }
|
||||
const response = await request(
|
||||
`/v1/plan/getWxMinAppCode`,
|
||||
params,
|
||||
"GET"
|
||||
);
|
||||
|
||||
// response 已经是 base64 字符串(因为 request 拦截器返回了 res.data.data)
|
||||
if (response && typeof response === 'string' && response.startsWith('data:image')) {
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: {
|
||||
qrCode: response, // base64 图片数据
|
||||
url: h5Url, // H5链接(确保即使失败也有)
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
throw new Error("二维码生成失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 即使二维码生成失败,也要保留H5链接
|
||||
Toast.show({
|
||||
content: error.message || "生成二维码失败",
|
||||
position: "top",
|
||||
});
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: {
|
||||
...prev[channelId],
|
||||
qrCode: "", // 二维码为空
|
||||
url: h5Url, // H5链接保留
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 显示二维码弹窗
|
||||
const handleShowQRCode = async (channel: { id: string | number; name: string; code?: string }) => {
|
||||
setCurrentQrChannel(channel);
|
||||
setShowQrDialog(true);
|
||||
|
||||
// 如果还没有生成二维码,则生成
|
||||
if (!qrCodeMap[channel.id]?.qrCode && !qrCodeMap[channel.id]?.loading) {
|
||||
await generateChannelQRCode(channel.id, channel.code);
|
||||
}
|
||||
};
|
||||
|
||||
// 同步分销相关的外部表单数据到本地状态
|
||||
useEffect(() => {
|
||||
setDistributionEnabled(formData.distributionEnabled ?? false);
|
||||
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||
setCustomerReward(formData.distributionCustomerReward);
|
||||
setAddReward(formData.distributionAddReward);
|
||||
}, [
|
||||
formData.distributionEnabled,
|
||||
formData.distributionChannelIds,
|
||||
formData.distributionCustomerReward,
|
||||
formData.distributionAddReward,
|
||||
]);
|
||||
|
||||
// 加载分销渠道列表,支持keyword和分页,强制只获取启用的渠道
|
||||
const loadDistributionChannels = useCallback(
|
||||
async (keyword: string = "", page: number = 1) => {
|
||||
setChannelLoading(true);
|
||||
try {
|
||||
const res = await fetchChannelList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
keyword: keyword.trim() || undefined,
|
||||
status: "enabled", // 强制只获取启用的渠道
|
||||
});
|
||||
setChannelList(res.list || []);
|
||||
setChannelTotal(res.total || 0);
|
||||
} catch (error: any) {
|
||||
// 错误处理
|
||||
} finally {
|
||||
setChannelLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleToggleDistribution = (value: boolean) => {
|
||||
setDistributionEnabled(value);
|
||||
// 关闭时清空已选渠道和奖励金额
|
||||
if (!value) {
|
||||
setTempSelectedChannelIds([]);
|
||||
setCustomerReward(undefined);
|
||||
setAddReward(undefined);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionEnabled: false,
|
||||
distributionChannelIds: [],
|
||||
distributionChannelsOptions: [],
|
||||
distributionCustomerReward: undefined,
|
||||
distributionAddReward: undefined,
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
...formData,
|
||||
distributionEnabled: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (channelModalVisible) {
|
||||
setChannelSearchQuery("");
|
||||
setChannelCurrentPage(1);
|
||||
// 复制一份已选渠道到临时变量
|
||||
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||
loadDistributionChannels("", 1);
|
||||
}
|
||||
}, [channelModalVisible, loadDistributionChannels, formData.distributionChannelIds]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!channelModalVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setChannelCurrentPage(1);
|
||||
loadDistributionChannels(channelSearchQuery, 1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [channelSearchQuery, channelModalVisible, loadDistributionChannels]);
|
||||
|
||||
// 翻页时重新请求
|
||||
useEffect(() => {
|
||||
if (!channelModalVisible) return;
|
||||
loadDistributionChannels(channelSearchQuery, channelCurrentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channelCurrentPage]);
|
||||
|
||||
const handleOpenChannelModal = () => {
|
||||
setChannelModalVisible(true);
|
||||
};
|
||||
|
||||
const handleChannelToggle = (channel: any) => {
|
||||
const id = channel.id;
|
||||
setTempSelectedChannelIds(prev =>
|
||||
prev.includes(id) ? prev.filter(v => v !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
// 直接使用从API返回的渠道列表(API已过滤为只返回启用的)
|
||||
const filteredChannels = channelList;
|
||||
|
||||
const channelTotalPages = Math.max(1, Math.ceil(channelTotal / PAGE_SIZE));
|
||||
|
||||
// 全选当前页
|
||||
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// 全选:添加当前页面所有未选中的渠道
|
||||
const currentPageChannels = filteredChannels.filter(
|
||||
(channel: any) => !tempSelectedChannelIds.includes(channel.id),
|
||||
);
|
||||
setTempSelectedChannelIds(prev => [
|
||||
...prev,
|
||||
...currentPageChannels.map((c: any) => c.id),
|
||||
]);
|
||||
} else {
|
||||
// 取消全选:移除当前页面的所有渠道
|
||||
const currentPageChannelIds = filteredChannels.map((c: any) => c.id);
|
||||
setTempSelectedChannelIds(prev =>
|
||||
prev.filter(id => !currentPageChannelIds.includes(id)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前页是否全选
|
||||
const isCurrentPageAllSelected =
|
||||
filteredChannels.length > 0 &&
|
||||
filteredChannels.every((channel: any) =>
|
||||
tempSelectedChannelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
const handleConfirmChannels = () => {
|
||||
const selectedOptions =
|
||||
channelList
|
||||
.filter(c => tempSelectedChannelIds.includes(c.id))
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
})) || [];
|
||||
|
||||
onChange({
|
||||
...formData,
|
||||
distributionEnabled: true,
|
||||
distributionChannelIds: tempSelectedChannelIds,
|
||||
distributionChannelsOptions: selectedOptions,
|
||||
});
|
||||
setDistributionEnabled(true);
|
||||
setChannelModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCancelChannels = () => {
|
||||
setChannelModalVisible(false);
|
||||
// 取消时恢复为表单中的已有值
|
||||
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||
};
|
||||
|
||||
// 获取显示文本(参考设备选择)
|
||||
const getChannelDisplayText = () => {
|
||||
const selectedChannels = formData.distributionChannelsOptions || [];
|
||||
if (selectedChannels.length === 0) return "";
|
||||
return `已选择 ${selectedChannels.length} 个渠道`;
|
||||
};
|
||||
|
||||
// 删除已选渠道
|
||||
const handleRemoveChannel = (id: string | number) => {
|
||||
const newChannelIds = (formData.distributionChannelIds || []).filter(
|
||||
(cid: string | number) => cid !== id
|
||||
);
|
||||
const newChannelOptions = (formData.distributionChannelsOptions || []).filter(
|
||||
(item: { id: string | number; name: string }) => item.id !== id
|
||||
);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionChannelIds: newChannelIds,
|
||||
distributionChannelsOptions: newChannelOptions,
|
||||
});
|
||||
};
|
||||
|
||||
// 清除所有已选渠道
|
||||
const handleClearAllChannels = () => {
|
||||
onChange({
|
||||
...formData,
|
||||
distributionChannelIds: [],
|
||||
distributionChannelsOptions: [],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["basic-container"]}>
|
||||
{/* 分销设置 */}
|
||||
<div className={styles["basic-distribution"]}>
|
||||
<div className={styles["basic-distribution-header"]}>
|
||||
<div>
|
||||
<div className={styles["basic-distribution-title"]}>分销设置</div>
|
||||
<div className={styles["basic-distribution-desc"]}>
|
||||
开启后,可将当前场景的获客用户同步到指定分销渠道
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={distributionEnabled}
|
||||
onChange={handleToggleDistribution}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{distributionEnabled && (
|
||||
<>
|
||||
{/* 输入框 - 参考设备选择样式 */}
|
||||
<div className={styles["distribution-input-wrapper"]}>
|
||||
<Input
|
||||
placeholder="选择分销渠道"
|
||||
value={getChannelDisplayText()}
|
||||
onClick={handleOpenChannelModal}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onClear={handleClearAllChannels}
|
||||
size="large"
|
||||
readOnly
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</div>
|
||||
{/* 已选渠道列表 - 参考设备选择样式 */}
|
||||
{formData.distributionChannelsOptions &&
|
||||
formData.distributionChannelsOptions.length > 0 ? (
|
||||
<div
|
||||
className={styles["distribution-selected-list"]}
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{formData.distributionChannelsOptions.map(
|
||||
(item: { id: string | number; name: string; code?: string }) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={styles["distribution-selected-item"]}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{/* 渠道图标 */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "6px",
|
||||
background:
|
||||
"linear-gradient(135deg, #1677ff 0%, #0958d9 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 2px 8px rgba(22, 119, 255, 0.25)",
|
||||
marginRight: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textShadow: "0 1px 3px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{(item.name || "渠")[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "#1a1a1a",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.code && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#8c8c8c",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
编码: {item.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<QrcodeOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
color: "#1890ff",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleShowQRCode(item)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveChannel(item.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{/* 奖励金额设置 */}
|
||||
<div className={styles["distribution-rewards"]}>
|
||||
<div className={styles["basic-label"]}>获客奖励金额(元)</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="请输入获客奖励金额"
|
||||
value={customerReward}
|
||||
onChange={e => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
setCustomerReward(value);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionCustomerReward: value,
|
||||
});
|
||||
}}
|
||||
min={0}
|
||||
step={0.01}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<div className={styles["basic-label"]}>添加奖励金额(元)</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="请输入添加奖励金额"
|
||||
value={addReward}
|
||||
onChange={e => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
setAddReward(value);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionAddReward: value,
|
||||
});
|
||||
}}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分销渠道选择弹框 - 参考设备选择样式 */}
|
||||
<Popup
|
||||
visible={channelModalVisible}
|
||||
onMaskClick={handleCancelChannels}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择分销渠道"
|
||||
searchQuery={channelSearchQuery}
|
||||
setSearchQuery={setChannelSearchQuery}
|
||||
searchPlaceholder="搜索渠道名称、编码..."
|
||||
loading={channelLoading}
|
||||
onRefresh={() => loadDistributionChannels(channelSearchQuery, channelCurrentPage)}
|
||||
showTabs={false}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
currentPage={channelCurrentPage}
|
||||
totalPages={channelTotalPages}
|
||||
loading={channelLoading}
|
||||
selectedCount={tempSelectedChannelIds.length}
|
||||
onPageChange={setChannelCurrentPage}
|
||||
onCancel={handleCancelChannels}
|
||||
onConfirm={handleConfirmChannels}
|
||||
isAllSelected={isCurrentPageAllSelected}
|
||||
onSelectAll={handleSelectAllCurrentPage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles["channelList"]}>
|
||||
{channelLoading && channelList.length === 0 ? (
|
||||
<div className={styles["loadingBox"]}>
|
||||
<div className={styles["loadingText"]}>加载中...</div>
|
||||
</div>
|
||||
) : filteredChannels.length === 0 ? (
|
||||
<div className={styles["loadingBox"]}>
|
||||
<div className={styles["loadingText"]}>
|
||||
暂无分销渠道,请先在「分销管理」中创建渠道
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles["channelListInner"]}>
|
||||
{filteredChannels.map((channel: any) => (
|
||||
<div key={channel.id} className={styles["channelItem"]}>
|
||||
{/* 顶部行:选择框和编码 */}
|
||||
<div className={styles["headerRow"]}>
|
||||
<div className={styles["checkboxContainer"]}>
|
||||
<Checkbox
|
||||
checked={tempSelectedChannelIds.includes(channel.id)}
|
||||
onChange={() => handleChannelToggle(channel)}
|
||||
className={styles["channelCheckbox"]}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles["codeText"]}>
|
||||
编码: {channel.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域:渠道信息 */}
|
||||
<div className={styles["mainContent"]}>
|
||||
{/* 渠道信息 */}
|
||||
<div className={styles["channelContent"]}>
|
||||
<div className={styles["channelInfoRow"]}>
|
||||
<span className={styles["channelName"]}>
|
||||
{channel.name}
|
||||
</span>
|
||||
<div
|
||||
className={
|
||||
channel.status === "enabled"
|
||||
? styles["statusEnabled"]
|
||||
: styles["statusDisabled"]
|
||||
}
|
||||
>
|
||||
{channel.status === "enabled" ? "启用" : "禁用"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["channelInfoDetail"]}>
|
||||
{channel.phone && (
|
||||
<div className={styles["infoItem"]}>
|
||||
<span className={styles["infoLabel"]}>手机号:</span>
|
||||
<span className={styles["infoValue"]}>
|
||||
{channel.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{channel.wechatId && (
|
||||
<div className={styles["infoItem"]}>
|
||||
<span className={styles["infoLabel"]}>微信号:</span>
|
||||
<span className={styles["infoValue"]}>
|
||||
{channel.wechatId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
|
||||
{/* 二维码弹窗 - 参考列表中的实现 */}
|
||||
<Popup
|
||||
visible={showQrDialog}
|
||||
onMaskClick={() => setShowQrDialog(false)}
|
||||
position="bottom"
|
||||
>
|
||||
<div style={{
|
||||
background: "#fff",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: "20px",
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
{currentQrChannel?.name || "渠道"}二维码
|
||||
</h3>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowQrDialog(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}>
|
||||
{currentQrChannel && (
|
||||
<>
|
||||
{qrCodeMap[currentQrChannel.id]?.loading ? (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "40px 20px",
|
||||
}}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
<div style={{ fontSize: 14, color: "#666" }}>生成二维码中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 二维码显示区域 */}
|
||||
{qrCodeMap[currentQrChannel.id]?.qrCode ? (
|
||||
<img
|
||||
src={qrCodeMap[currentQrChannel.id].qrCode}
|
||||
alt="渠道二维码"
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "40px 20px",
|
||||
color: "#999",
|
||||
}}>
|
||||
<QrcodeOutlined style={{ fontSize: 48 }} />
|
||||
<div style={{ fontSize: 14 }}>二维码生成失败</div>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => currentQrChannel && generateChannelQRCode(currentQrChannel.id, currentQrChannel.code)}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* H5链接展示 - 无论二维码是否成功都要显示 */}
|
||||
{qrCodeMap[currentQrChannel.id]?.url && (
|
||||
<div style={{
|
||||
width: "100%",
|
||||
marginTop: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
marginBottom: 8,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
H5链接
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<Input
|
||||
value={qrCodeMap[currentQrChannel.id].url}
|
||||
readOnly
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const link = qrCodeMap[currentQrChannel.id].url;
|
||||
navigator.clipboard.writeText(link);
|
||||
Toast.show({
|
||||
content: "链接已复制到剪贴板",
|
||||
position: "top",
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DistributionSettings;
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { Input, Switch } from "antd";
|
||||
import styles from "./base.module.scss";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import type { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
|
||||
interface GroupSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
}
|
||||
|
||||
const GroupSettings: React.FC<GroupSettingsProps> = ({ formData, onChange }) => {
|
||||
const handleToggleGroupInvite = (value: boolean) => {
|
||||
onChange({
|
||||
...formData,
|
||||
groupInviteEnabled: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...formData,
|
||||
groupName: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFixedMembersSelect = (friends: FriendSelectionItem[]) => {
|
||||
onChange({
|
||||
...formData,
|
||||
fixedGroupMembers: friends,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["basic-container"]}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
border: "1px solid #f0f0f0",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
拉群设置
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#8c8c8c" }}>
|
||||
开启后,可配置群名称和固定群成员,将用户引导到指定微信群
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!formData.groupInviteEnabled}
|
||||
onChange={handleToggleGroupInvite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.groupInviteEnabled && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Input
|
||||
placeholder="请输入群名称"
|
||||
value={formData.groupName || ""}
|
||||
onChange={handleGroupNameChange}
|
||||
/>
|
||||
{/* 固定成员选择,复用好友选择组件 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
marginBottom: 4,
|
||||
color: "#595959",
|
||||
}}
|
||||
>
|
||||
固定群成员
|
||||
</div>
|
||||
<FriendSelection
|
||||
selectedOptions={
|
||||
(formData.fixedGroupMembers || []) as FriendSelectionItem[]
|
||||
}
|
||||
onSelect={handleFixedMembersSelect}
|
||||
placeholder="选择固定群成员"
|
||||
showSelectedList={true}
|
||||
// 根据已选择设备过滤好友列表
|
||||
deviceIds={formData.deviceGroups || []}
|
||||
enableDeviceFilter={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupSettings;
|
||||
@@ -29,6 +29,17 @@
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
.basic-scene-btn.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
background: rgba(#1677ff, 0.1);
|
||||
color: #1677ff;
|
||||
}
|
||||
.basic-scene-btn.disabled.selected {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.basic-label {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
@@ -161,3 +172,214 @@
|
||||
justify-content: space-between;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
// 分销渠道选择弹框样式 - 参考设备选择
|
||||
.channelList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.channelListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.channelItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #f5f5f5;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.codeText {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.channelCheckbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channelContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channelInfoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.channelName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusEnabled {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statusDisabled {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.channelInfoDetail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
|
||||
&.customerCount {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
// 分销设置
|
||||
.basic-distribution {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
|
||||
.basic-distribution-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.basic-distribution-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.basic-distribution-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.distribution-input-wrapper {
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-input {
|
||||
padding-left: 38px !important;
|
||||
height: 56px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.distribution-selected-list {
|
||||
.distribution-selected-item {
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.distribution-rewards {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e6eb;
|
||||
}
|
||||
}
|
||||
|
||||
.basic-distribution-modal-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.basic-distribution-modal-tag {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -160,31 +160,31 @@ export function deleteContent(contentId: string) {
|
||||
return request(`/api/contents/${contentId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 流量池相关接口 ====================
|
||||
// ==================== 流量池相关接口 (V2) ====================
|
||||
|
||||
// 获取流量池列表
|
||||
// 获取流量池分组列表
|
||||
export function getTrafficPools() {
|
||||
return request("/api/traffic-pools", undefined, "GET");
|
||||
return request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取流量池详情
|
||||
// 获取流量池用户详情
|
||||
export function getTrafficPoolDetail(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, "GET");
|
||||
return request("/v1/traffic/pool/v2/detail", { id: poolId }, "GET");
|
||||
}
|
||||
|
||||
// 创建流量池
|
||||
export function createTrafficPool(data: any) {
|
||||
return request("/api/traffic-pools", data, "POST");
|
||||
// 创建流量池分组
|
||||
export function createTrafficPool(data: { groupName: string; description?: string; ruleType?: number; ruleConfig?: any }) {
|
||||
return request("/v1/traffic/pool/v2/group/create", data, "POST");
|
||||
}
|
||||
|
||||
// 更新流量池
|
||||
export function updateTrafficPool(poolId: string, data: any) {
|
||||
return request(`/api/traffic-pools/${poolId}`, data, "PUT");
|
||||
// 更新流量池分组
|
||||
export function updateTrafficPool(groupId: string, data: any) {
|
||||
return request("/v1/traffic/pool/v2/group/update", { groupId, ...data }, "PUT");
|
||||
}
|
||||
|
||||
// 删除流量池
|
||||
export function deleteTrafficPool(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, "DELETE");
|
||||
// 删除流量池分组
|
||||
export function deleteTrafficPool(groupId: string) {
|
||||
return request("/v1/traffic/pool/v2/group/delete", { groupId }, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 工作台相关接口 ====================
|
||||
|
||||
97
Cunkebao/src/pages/mobile/test/api.ts
Normal file
97
Cunkebao/src/pages/mobile/test/api.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import axios from "axios";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { generateSign } from "./utils/sign";
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = "https://ckbapi.quwanzhi.com/v1/api";
|
||||
// 默认API Key(用于测试)
|
||||
export const DEFAULT_API_KEY = "v3pzy-zcfkg-96jio-7xgh6-14kio";
|
||||
|
||||
export interface SubmitLeadParams {
|
||||
phone: string;
|
||||
name: string;
|
||||
source: string;
|
||||
remark?: string;
|
||||
wechatId?: string;
|
||||
tags?: string;
|
||||
siteTags?: string;
|
||||
}
|
||||
|
||||
export interface SubmitLeadResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交线索到存客宝
|
||||
* @param params 线索参数
|
||||
* @param apiKey API密钥(必填)
|
||||
*/
|
||||
export async function submitLead(
|
||||
params: SubmitLeadParams,
|
||||
apiKey: string,
|
||||
): Promise<SubmitLeadResponse> {
|
||||
if (!apiKey) {
|
||||
throw new Error("apiKey不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
// 生成时间戳(秒级)
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// 构建请求参数
|
||||
const requestParams: Record<string, any> = {
|
||||
apiKey: apiKey,
|
||||
timestamp,
|
||||
phone: params.phone,
|
||||
name: params.name,
|
||||
source: params.source,
|
||||
};
|
||||
|
||||
// 添加可选字段(只添加非空值)
|
||||
if (params.remark) {
|
||||
requestParams.remark = params.remark;
|
||||
}
|
||||
if (params.wechatId) {
|
||||
requestParams.wechatId = params.wechatId;
|
||||
}
|
||||
if (params.tags) {
|
||||
requestParams.tags = params.tags;
|
||||
}
|
||||
if (params.siteTags) {
|
||||
requestParams.siteTags = params.siteTags;
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
const sign = generateSign(requestParams, apiKey);
|
||||
requestParams.sign = sign;
|
||||
|
||||
// 发送请求
|
||||
const response = await axios.post<SubmitLeadResponse>(
|
||||
`${API_BASE_URL}/scenarios`,
|
||||
requestParams,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 20000,
|
||||
},
|
||||
);
|
||||
|
||||
const result = response.data;
|
||||
|
||||
// 处理响应
|
||||
if (result.code === 200) {
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(result.message || "提交失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
"网络请求失败,请稍后重试";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.modalContainer {
|
||||
background: #fff;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.formFooter {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
201
Cunkebao/src/pages/mobile/test/components/TestFormModal.tsx
Normal file
201
Cunkebao/src/pages/mobile/test/components/TestFormModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Popup, Form, Input, TextArea, Button, Toast } from "antd-mobile";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import styles from "./TestFormModal.module.scss";
|
||||
import { submitLead, DEFAULT_API_KEY } from "../api";
|
||||
|
||||
interface TestFormModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (values: TestFormValues) => void;
|
||||
}
|
||||
|
||||
export interface TestFormValues {
|
||||
apiKey: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
source: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
const TestFormModal: React.FC<TestFormModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
// 获取API Key,如果没有输入则使用默认值
|
||||
const apiKey = values.apiKey?.trim() || DEFAULT_API_KEY;
|
||||
|
||||
// 调用API提交数据
|
||||
const result = await submitLead(
|
||||
{
|
||||
phone: values.phone,
|
||||
name: values.name,
|
||||
source: values.source,
|
||||
remark: values.remark || undefined,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
|
||||
// 调用提交回调(如果提供)
|
||||
if (onSubmit) {
|
||||
onSubmit(values as TestFormValues);
|
||||
}
|
||||
|
||||
Toast.show({
|
||||
content: result.message || "提交成功",
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
// 重置表单并关闭弹框
|
||||
form.resetFields();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("提交失败:", error);
|
||||
Toast.show({
|
||||
content: error.message || "提交失败,请稍后重试",
|
||||
icon: "fail",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.resetFields();
|
||||
// 重置时恢复默认API Key
|
||||
form.setFieldsValue({ apiKey: DEFAULT_API_KEY });
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 初始化表单默认值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.setFieldsValue({ apiKey: DEFAULT_API_KEY });
|
||||
}
|
||||
}, [visible, form]);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={handleClose}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "90vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<div className={styles.modalContainer}>
|
||||
{/* 头部 */}
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 className={styles.modalTitle}>测试表单</h3>
|
||||
<CloseOutlined
|
||||
className={styles.closeIcon}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<div className={styles.modalContent}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
footer={
|
||||
<div className={styles.formFooter}>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
style={{ marginRight: 12 }}
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
block
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
label="API Key"
|
||||
name="apiKey"
|
||||
rules={[]}
|
||||
extra="留空则使用默认API Key"
|
||||
>
|
||||
<Input
|
||||
placeholder={`默认: ${DEFAULT_API_KEY}`}
|
||||
defaultValue={DEFAULT_API_KEY}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: "请输入手机号" },
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: "请输入正确的手机号",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入手机号"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: "请输入姓名" },
|
||||
{ max: 20, message: "姓名不能超过20个字符" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入姓名" maxLength={20} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="来源"
|
||||
name="source"
|
||||
rules={[{ required: true, message: "请输入来源" }]}
|
||||
>
|
||||
<Input placeholder="请输入来源" maxLength={50} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="备注"
|
||||
name="remark"
|
||||
rules={[{ max: 200, message: "备注不能超过200个字符" }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入备注(选填)"
|
||||
showCount
|
||||
maxLength={200}
|
||||
rows={3}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestFormModal;
|
||||
@@ -1,19 +1,28 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Card, Button, Space, Typography, Tag } from "antd";
|
||||
import {
|
||||
MessageOutlined,
|
||||
SelectOutlined,
|
||||
UploadOutlined,
|
||||
FormOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isDevelopment } from "@/utils/env";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import TestFormModal, { TestFormValues } from "./components/TestFormModal";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const TestIndex: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [testFormVisible, setTestFormVisible] = useState(false);
|
||||
|
||||
const handleTestFormSubmit = (values: TestFormValues) => {
|
||||
// API调用已在TestFormModal内部完成
|
||||
// 这里可以添加额外的处理逻辑,如日志记录、数据分析等
|
||||
console.log("测试表单提交成功,数据:", values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="测试页面" />}>
|
||||
@@ -60,11 +69,31 @@ const TestIndex: React.FC = () => {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="功能测试" size="small">
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Button
|
||||
icon={<FormOutlined />}
|
||||
size="large"
|
||||
block
|
||||
onClick={() => setTestFormVisible(true)}
|
||||
>
|
||||
测试表单
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="说明" size="small">
|
||||
<Text>这里提供各种功能的测试页面,方便开发和调试。</Text>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 测试表单弹框 */}
|
||||
<TestFormModal
|
||||
visible={testFormVisible}
|
||||
onClose={() => setTestFormVisible(false)}
|
||||
onSubmit={handleTestFormSubmit}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
54
Cunkebao/src/pages/mobile/test/utils/sign.ts
Normal file
54
Cunkebao/src/pages/mobile/test/utils/sign.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 签名生成工具
|
||||
* 根据API文档的签名规则生成MD5签名
|
||||
*/
|
||||
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
/**
|
||||
* 生成MD5哈希值
|
||||
*/
|
||||
function md5(text: string): string {
|
||||
return CryptoJS.MD5(text).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
* 根据API文档的签名规则生成MD5签名
|
||||
*/
|
||||
export function generateSign(
|
||||
params: Record<string, any>,
|
||||
apiKey: string,
|
||||
): string {
|
||||
// 第一步:移除 sign、apiKey、portrait
|
||||
const filteredParams: Record<string, any> = { ...params };
|
||||
delete filteredParams.sign;
|
||||
delete filteredParams.apiKey;
|
||||
delete filteredParams.portrait;
|
||||
|
||||
// 第二步:移除空值(null 和空字符串)
|
||||
const nonEmptyParams: Record<string, any> = {};
|
||||
for (const key in filteredParams) {
|
||||
const value = filteredParams[key];
|
||||
if (value !== null && value !== "" && value !== undefined) {
|
||||
nonEmptyParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 第三步:按参数名升序排序
|
||||
const sortedKeys = Object.keys(nonEmptyParams).sort();
|
||||
|
||||
// 第四步:拼接参数值
|
||||
let stringToSign = "";
|
||||
for (const key of sortedKeys) {
|
||||
stringToSign += String(nonEmptyParams[key]);
|
||||
}
|
||||
|
||||
// 第五步:第一次MD5
|
||||
const firstMd5 = md5(stringToSign);
|
||||
|
||||
// 第六步:拼接apiKey后第二次MD5
|
||||
const finalSign = md5(firstMd5 + apiKey);
|
||||
|
||||
return finalSign;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useImperativeHandle, forwardRef } from "react";
|
||||
import { Form, Card, Tabs } from "antd";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
|
||||
interface OwnerAdminSelectorProps {
|
||||
selectedOwners: DeviceSelectionItem[];
|
||||
selectedAdmins: FriendSelectionItem[];
|
||||
onNext: (data: {
|
||||
devices: string[];
|
||||
devicesOptions: DeviceSelectionItem[];
|
||||
admins: string[];
|
||||
adminsOptions: FriendSelectionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface OwnerAdminSelectorRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const OwnerAdminSelector = forwardRef<
|
||||
OwnerAdminSelectorRef,
|
||||
OwnerAdminSelectorProps
|
||||
>(({ selectedOwners, selectedAdmins, onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
const [owners, setOwners] = React.useState<DeviceSelectionItem[]>(
|
||||
selectedOwners || []
|
||||
);
|
||||
const [admins, setAdmins] = React.useState<FriendSelectionItem[]>(
|
||||
selectedAdmins || []
|
||||
);
|
||||
|
||||
// 当外部传入的 selectedOwners 或 selectedAdmins 变化时,同步内部状态
|
||||
React.useEffect(() => {
|
||||
setOwners(selectedOwners || []);
|
||||
}, [selectedOwners]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAdmins(selectedAdmins || []);
|
||||
}, [selectedAdmins]);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
// 验证群主和管理员
|
||||
if (owners.length === 0) {
|
||||
form.setFields([
|
||||
{
|
||||
name: "devices",
|
||||
errors: ["请选择一个群主"],
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
if (owners.length > 1) {
|
||||
form.setFields([
|
||||
{
|
||||
name: "devices",
|
||||
errors: ["群主只能选择一个设备"],
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
if (admins.length === 0) {
|
||||
form.setFields([
|
||||
{
|
||||
name: "admins",
|
||||
errors: ["请至少选择一个管理员"],
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
// 清除错误
|
||||
form.setFields([
|
||||
{
|
||||
name: "devices",
|
||||
errors: [],
|
||||
},
|
||||
{
|
||||
name: "admins",
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
},
|
||||
getValues: () => {
|
||||
return {
|
||||
devices: owners.map(o => o.id.toString()),
|
||||
admins: admins.map(a => a.id.toString()),
|
||||
devicesOptions: owners,
|
||||
adminsOptions: admins,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// 群主选择(设备选择)
|
||||
const handleOwnersSelect = (selectedDevices: DeviceSelectionItem[]) => {
|
||||
const previousOwnerId = owners.length > 0 ? owners[0]?.id : null;
|
||||
const newOwnerId = selectedDevices.length > 0 ? selectedDevices[0]?.id : null;
|
||||
|
||||
// 当群主改变时,清空已选的管理员(因为筛选条件变了)
|
||||
const shouldClearAdmins = previousOwnerId !== newOwnerId;
|
||||
|
||||
setOwners(selectedDevices);
|
||||
const ownerIds = selectedDevices.map(d => d.id.toString());
|
||||
form.setFieldValue("devices", ownerIds);
|
||||
|
||||
if (shouldClearAdmins) {
|
||||
setAdmins([]);
|
||||
form.setFieldValue("admins", []);
|
||||
}
|
||||
|
||||
// 通知父组件数据变化
|
||||
onNext({
|
||||
devices: ownerIds,
|
||||
devicesOptions: selectedDevices,
|
||||
admins: shouldClearAdmins ? [] : admins.map(a => a.id.toString()),
|
||||
adminsOptions: shouldClearAdmins ? [] : admins,
|
||||
});
|
||||
};
|
||||
|
||||
// 管理员选择
|
||||
const handleAdminsSelect = (selectedFriends: FriendSelectionItem[]) => {
|
||||
setAdmins(selectedFriends);
|
||||
const adminIds = selectedFriends.map(f => f.id.toString());
|
||||
form.setFieldValue("admins", adminIds);
|
||||
// 通知父组件数据变化
|
||||
onNext({
|
||||
devices: owners.map(o => o.id.toString()),
|
||||
devicesOptions: owners,
|
||||
admins: adminIds,
|
||||
adminsOptions: selectedFriends,
|
||||
});
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "devices",
|
||||
label: `群主 (${owners.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p style={{ margin: "0 0 8px 0", color: "#666", fontSize: 14 }}>
|
||||
请选择一个群主(设备),该设备将作为新建群聊的群主
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="devices"
|
||||
validateStatus={owners.length === 0 || owners.length > 1 ? "error" : ""}
|
||||
help={
|
||||
owners.length === 0
|
||||
? "请选择一个群主(设备)"
|
||||
: owners.length > 1
|
||||
? "群主只能选择一个设备"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<DeviceSelection
|
||||
selectedOptions={owners}
|
||||
onSelect={handleOwnersSelect}
|
||||
placeholder="选择群主(设备)"
|
||||
singleSelect={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "admins",
|
||||
label: `管理员 (${admins.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p style={{ margin: "0 0 8px 0", color: "#666", fontSize: 14 }}>
|
||||
{owners.length === 0
|
||||
? "请先选择群主(设备),然后选择该设备下的好友作为管理员"
|
||||
: "请选择管理员,管理员将协助管理新建的群聊(仅显示所选设备下的好友)"}
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="admins"
|
||||
validateStatus={admins.length === 0 ? "error" : ""}
|
||||
help={
|
||||
owners.length === 0
|
||||
? "请先选择群主(设备)"
|
||||
: admins.length === 0
|
||||
? "请至少选择一个管理员"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<FriendSelection
|
||||
selectedOptions={admins}
|
||||
onSelect={handleAdminsSelect}
|
||||
placeholder={owners.length === 0 ? "请先选择群主" : "选择管理员"}
|
||||
deviceIds={owners.length > 0 ? owners.map(d => d.id) : []}
|
||||
enableDeviceFilter={true}
|
||||
readonly={owners.length === 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
devices: (selectedOwners || []).map(item => item.id.toString()),
|
||||
admins: (selectedAdmins || []).map(item => item.id.toString()),
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
选择群主和管理员
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
请选择一个群主(设备)和管理员(好友),他们将负责管理新建的群聊
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs items={tabItems} />
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
OwnerAdminSelector.displayName = "OwnerAdminSelector";
|
||||
|
||||
export default OwnerAdminSelector;
|
||||
@@ -7,33 +7,38 @@ import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
|
||||
import { AutoGroupFormData, StepItem } from "./types";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
||||
import DeviceSelector, { DeviceSelectorRef } from "./components/DeviceSelector";
|
||||
import OwnerAdminSelector, {
|
||||
OwnerAdminSelectorRef,
|
||||
} from "./components/OwnerAdminSelector";
|
||||
import PoolSelector, { PoolSelectorRef } from "./components/PoolSelector";
|
||||
import NavCommon from "@/components/NavCommon/index";
|
||||
import dayjs from "dayjs";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
|
||||
const steps: StepItem[] = [
|
||||
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤 2", subtitle: "选择设备" },
|
||||
{ id: 2, title: "步骤 2", subtitle: "选择群主和管理员" },
|
||||
{ id: 3, title: "步骤 3", subtitle: "选择流量池包" },
|
||||
];
|
||||
|
||||
const defaultForm: AutoGroupFormData = {
|
||||
name: "",
|
||||
type: 4,
|
||||
deviceGroups: [], // 设备组
|
||||
deviceGroupsOptions: [], // 设备组选项
|
||||
devices: [], // 群主ID列表
|
||||
devicesOptions: [], // 群主选项
|
||||
admins: [], // 管理员ID列表
|
||||
adminsOptions: [], // 管理员选项
|
||||
poolGroups: [], // 内容库
|
||||
poolGroupsOptions: [], // 内容库选项
|
||||
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
|
||||
endTime: dayjs().add(1, "hour").format("HH:mm"), // 结束时间 (HH:mm)
|
||||
groupSizeMin: 20, // 群组最小人数
|
||||
groupSizeMax: 50, // 群组最大人数
|
||||
groupSizeMax: 50, // 群组最大人数
|
||||
maxGroupsPerDay: 10, // 每日最大建群数
|
||||
groupNameTemplate: "", // 群名称模板
|
||||
groupDescription: "", // 群描述
|
||||
groupDescription: "", // 群描述
|
||||
status: 1, // 是否启用 (1: 启用, 0: 禁用)
|
||||
};
|
||||
|
||||
@@ -45,16 +50,15 @@ const AutoGroupForm: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoaded, setDataLoaded] = useState(!isEdit); // 非编辑模式直接标记为已加载
|
||||
const [formData, setFormData] = useState<AutoGroupFormData>(defaultForm);
|
||||
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<
|
||||
DeviceSelectionItem[]
|
||||
>([]);
|
||||
const [devicesOptions, setDevicesOptions] = useState<DeviceSelectionItem[]>([]);
|
||||
const [adminsOptions, setAdminsOptions] = useState<FriendSelectionItem[]>([]);
|
||||
const [poolGroupsOptions, setpoolGroupsOptions] = useState<
|
||||
PoolSelectionItem[]
|
||||
>([]);
|
||||
|
||||
// 创建子组件的ref
|
||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||
const deviceSelectorRef = useRef<DeviceSelectorRef>(null);
|
||||
const ownerAdminSelectorRef = useRef<OwnerAdminSelectorRef>(null);
|
||||
const poolSelectorRef = useRef<PoolSelectorRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,8 +68,10 @@ const AutoGroupForm: React.FC = () => {
|
||||
const updatedForm = {
|
||||
...defaultForm,
|
||||
name: res.name,
|
||||
deviceGroups: res.config.deviceGroups || [],
|
||||
deviceGroupsOptions: res.config.deviceGroupsOptions || [],
|
||||
devices: res.config.deviceGroups || res.config.devices || [], // 兼容deviceGroups和devices
|
||||
devicesOptions: res.config.deviceGroupsOptions || res.config.devicesOptions || [], // 兼容deviceGroupsOptions和devicesOptions
|
||||
admins: res.config.admins || [],
|
||||
adminsOptions: res.config.adminsOptions || [],
|
||||
poolGroups: res.config.poolGroups || [],
|
||||
poolGroupsOptions: res.config.poolGroupsOptions || [],
|
||||
startTime: res.config.startTime,
|
||||
@@ -80,7 +86,8 @@ const AutoGroupForm: React.FC = () => {
|
||||
id: res.id,
|
||||
};
|
||||
setFormData(updatedForm);
|
||||
setDeviceGroupsOptions(res.config.deviceGroupsOptions || []);
|
||||
setDevicesOptions(res.config.deviceGroupsOptions || res.config.devicesOptions || []); // 兼容deviceGroupsOptions和devicesOptions
|
||||
setAdminsOptions(res.config.adminsOptions || []);
|
||||
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
|
||||
setDataLoaded(true); // 标记数据已加载
|
||||
});
|
||||
@@ -90,16 +97,20 @@ const AutoGroupForm: React.FC = () => {
|
||||
setFormData(prev => ({ ...prev, ...values }));
|
||||
};
|
||||
|
||||
// 设备组选择
|
||||
const handleDevicesChange = (data: {
|
||||
deviceGroups: string[];
|
||||
deviceGroupsOptions: DeviceSelectionItem[];
|
||||
// 群主和管理员选择
|
||||
const handleOwnerAdminChange = (data: {
|
||||
devices: string[];
|
||||
devicesOptions: DeviceSelectionItem[];
|
||||
admins: string[];
|
||||
adminsOptions: FriendSelectionItem[];
|
||||
}) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
deviceGroups: data.deviceGroups,
|
||||
devices: data.devices,
|
||||
admins: data.admins,
|
||||
}));
|
||||
setDeviceGroupsOptions(data.deviceGroupsOptions);
|
||||
setDevicesOptions(data.devicesOptions);
|
||||
setAdminsOptions(data.adminsOptions);
|
||||
};
|
||||
|
||||
// 流量池包选择
|
||||
@@ -116,8 +127,16 @@ const AutoGroupForm: React.FC = () => {
|
||||
Toast.show({ content: "请输入任务名称" });
|
||||
return;
|
||||
}
|
||||
if (formData.deviceGroups.length === 0) {
|
||||
Toast.show({ content: "请选择至少一个设备组" });
|
||||
if (formData.devices.length === 0) {
|
||||
Toast.show({ content: "请选择一个群主" });
|
||||
return;
|
||||
}
|
||||
if (formData.devices.length > 1) {
|
||||
Toast.show({ content: "群主只能选择一个设备" });
|
||||
return;
|
||||
}
|
||||
if (formData.admins.length === 0) {
|
||||
Toast.show({ content: "请至少选择一个管理员" });
|
||||
return;
|
||||
}
|
||||
if (formData.poolGroups.length === 0) {
|
||||
@@ -127,9 +146,13 @@ const AutoGroupForm: React.FC = () => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 构建提交数据,将devices映射为deviceGroups
|
||||
const { devices, devicesOptions, ...restFormData } = formData;
|
||||
const submitData = {
|
||||
...formData,
|
||||
deviceGroupsOptions: deviceGroupsOptions,
|
||||
...restFormData,
|
||||
deviceGroups: devices, // 设备ID数组,传输字段名为deviceGroups
|
||||
deviceGroupsOptions: devicesOptions, // 设备完整信息,传输字段名为deviceGroupsOptions
|
||||
adminsOptions: adminsOptions,
|
||||
poolGroupsOptions: poolGroupsOptions,
|
||||
};
|
||||
|
||||
@@ -173,8 +196,9 @@ const AutoGroupForm: React.FC = () => {
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// 调用 DeviceSelector 的表单校验
|
||||
isValid = (await deviceSelectorRef.current?.validate()) || false;
|
||||
// 调用 OwnerAdminSelector 的表单校验
|
||||
isValid =
|
||||
(await ownerAdminSelectorRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
@@ -217,10 +241,11 @@ const AutoGroupForm: React.FC = () => {
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<DeviceSelector
|
||||
ref={deviceSelectorRef}
|
||||
selectedDevices={deviceGroupsOptions}
|
||||
onNext={handleDevicesChange}
|
||||
<OwnerAdminSelector
|
||||
ref={ownerAdminSelectorRef}
|
||||
selectedOwners={devicesOptions}
|
||||
selectedAdmins={adminsOptions}
|
||||
onNext={handleOwnerAdminChange}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
|
||||
// 自动建群表单数据类型定义
|
||||
export interface AutoGroupFormData {
|
||||
id?: string; // 任务ID
|
||||
type: number; // 任务类型
|
||||
name: string; // 任务名称
|
||||
deviceGroups: string[]; // 设备组
|
||||
deviceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
|
||||
devices: string[]; // 群主ID列表(设备ID)
|
||||
devicesOptions: DeviceSelectionItem[]; // 群主选项(设备)
|
||||
admins: string[]; // 管理员ID列表(好友ID)
|
||||
adminsOptions: FriendSelectionItem[]; // 管理员选项(好友)
|
||||
poolGroups: string[]; // 流量池
|
||||
poolGroupsOptions: PoolSelectionItem[]; // 流量池选项
|
||||
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
|
||||
@@ -34,9 +37,13 @@ export const formValidationRules = {
|
||||
{ required: true, message: "请输入任务名称" },
|
||||
{ min: 2, max: 50, message: "任务名称长度应在2-50个字符之间" },
|
||||
],
|
||||
deviceGroups: [
|
||||
{ required: true, message: "请选择设备组" },
|
||||
{ type: "array", min: 1, message: "至少选择一个设备组" },
|
||||
devices: [
|
||||
{ required: true, message: "请选择群主" },
|
||||
{ type: "array", min: 1, max: 1, message: "群主只能选择一个设备" },
|
||||
],
|
||||
admins: [
|
||||
{ required: true, message: "请选择管理员" },
|
||||
{ type: "array", min: 1, message: "至少选择一个管理员" },
|
||||
],
|
||||
poolGroups: [
|
||||
{ required: true, message: "请选择内容库" },
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// 页面容器
|
||||
.auto-like-page {
|
||||
padding: 0 16px 24px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -252,6 +258,50 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 计划分组(全局 / 独立)
|
||||
.info-box {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.section-dot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.section-title-independent {
|
||||
.section-dot {
|
||||
background: #fb923c;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-list-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.task-info {
|
||||
@@ -272,7 +322,7 @@
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 12px;
|
||||
.auto-like-page {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,20 +116,39 @@ const AutoLike: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const Res: any = await fetchAutoLikeTasks();
|
||||
// 直接就是任务数组,无需再解包
|
||||
const mappedTasks = Res?.list?.map((task: any) => ({
|
||||
...task,
|
||||
status: task.status || 2, // 默认为关闭状态
|
||||
deviceCount: task.deviceCount || 0,
|
||||
targetGroup: task.targetGroup || "全部好友",
|
||||
likeInterval: task.likeInterval || 60,
|
||||
maxLikesPerDay: task.maxLikesPerDay || 100,
|
||||
lastLikeTime: task.lastLikeTime || "暂无",
|
||||
createTime: task.createTime || "",
|
||||
updateTime: task.updateTime || "",
|
||||
todayLikeCount: task.todayLikeCount || 0,
|
||||
totalLikeCount: task.totalLikeCount || 0,
|
||||
}));
|
||||
// 数据在 data.list 中
|
||||
const taskList = Res?.data?.list || Res?.list || [];
|
||||
const mappedTasks: LikeTask[] = taskList.map((task: any) => {
|
||||
const config = task.config || {};
|
||||
const friends = config.friends || [];
|
||||
const devices = config.devices || [];
|
||||
|
||||
// 判断目标人群:如果 friends 为空或未设置,表示选择全部好友
|
||||
let targetGroup = "全部好友";
|
||||
if (friends.length > 0) {
|
||||
targetGroup = `${friends.length} 个好友`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id?.toString() || "",
|
||||
name: task.name || "",
|
||||
status: task.status === 1 ? 1 : 2, // 1: 开启, 2: 关闭
|
||||
deviceCount: devices.length,
|
||||
targetGroup: targetGroup,
|
||||
likeInterval: config.interval || 60,
|
||||
maxLikesPerDay: config.maxLikes || 100,
|
||||
lastLikeTime: task.lastLikeTime || "暂无",
|
||||
createTime: task.createTime || "",
|
||||
updateTime: task.updateTime || "",
|
||||
todayLikeCount: config.todayLikeCount || 0,
|
||||
totalLikeCount: config.totalLikeCount || 0,
|
||||
planType: config.planType ?? task.planType ?? 1,
|
||||
// 保留原始数据
|
||||
config: config,
|
||||
devices: devices,
|
||||
friends: friends,
|
||||
};
|
||||
});
|
||||
setTasks(mappedTasks);
|
||||
} catch (error) {
|
||||
console.error("获取自动点赞任务失败:", error);
|
||||
@@ -225,6 +244,153 @@ const AutoLike: React.FC = () => {
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 按计划类型分组
|
||||
const globalTasks = filteredTasks.filter(t => t.planType === 0);
|
||||
const independentTasks = filteredTasks.filter(t => t.planType !== 0);
|
||||
|
||||
const renderTaskCard = (task: LikeTask) => (
|
||||
<Card key={task.id} className={style["task-card"]}>
|
||||
<div className={style["task-header"]}>
|
||||
<div className={style["task-title-section"]}>
|
||||
<h3 className={style["task-name"]}>{task.name}</h3>
|
||||
<span
|
||||
className={`${style["task-status"]} ${
|
||||
Number(task.status) === 1 ? style["active"] : style["inactive"]
|
||||
}`}
|
||||
>
|
||||
{Number(task.status) === 1 ? "进行中" : "已暂停"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["task-controls"]}>
|
||||
<label className={style["switch"]}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Number(task.status) === 1}
|
||||
onChange={() => toggleTaskStatus(task.id, Number(task.status))}
|
||||
/>
|
||||
<span className={style["slider"]}></span>
|
||||
</label>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-info"]}>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>执行设备:</span>
|
||||
<span className={style["info-value"]}>{task.deviceCount} 个</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>目标人群:</span>
|
||||
<span className={style["info-value"]}>{task.targetGroup}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>点赞间隔:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.likeInterval} 秒
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>每日上限:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.maxLikesPerDay} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-stats"]}>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["blue"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>今日点赞:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.todayLikeCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["green"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>总点赞数:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.totalLikeCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (loading) {
|
||||
content = (
|
||||
<div className={style["task-list"]}>
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style["loading-text"]}>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (filteredTasks.length === 0) {
|
||||
content = (
|
||||
<div className={style["task-list"]}>
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>
|
||||
<LikeOutlined />
|
||||
</div>
|
||||
<div className={style["empty-text"]}>暂无自动点赞任务</div>
|
||||
<div className={style["empty-subtext"]}>
|
||||
点击右上角按钮创建新任务
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
{globalTasks.length > 0 && (
|
||||
<div className={style["info-box"]}>
|
||||
全局计划将应用于所有设备(包括新添加的设备),请谨慎配置点赞频率和数量,避免账号风险。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalTasks.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h3 className={style["section-title"]}>
|
||||
<span className={style["section-dot"]} />
|
||||
全局自动点赞计划
|
||||
</h3>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{globalTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{independentTasks.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h3
|
||||
className={`${style["section-title"]} ${style["section-title-independent"]}`}
|
||||
>
|
||||
<span className={style["section-dot"]} />
|
||||
独立自动点赞计划
|
||||
</h3>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{independentTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
@@ -263,116 +429,7 @@ const AutoLike: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["auto-like-page"]}>
|
||||
{/* 任务列表 */}
|
||||
<div className={style["task-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style["loading-text"]}>加载中...</div>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>
|
||||
<LikeOutlined />
|
||||
</div>
|
||||
<div className={style["empty-text"]}>暂无自动点赞任务</div>
|
||||
<div className={style["empty-subtext"]}>
|
||||
点击右上角按钮创建新任务
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
<Card key={task.id} className={style["task-card"]}>
|
||||
<div className={style["task-header"]}>
|
||||
<div className={style["task-title-section"]}>
|
||||
<h3 className={style["task-name"]}>{task.name}</h3>
|
||||
<span
|
||||
className={`${style["task-status"]} ${
|
||||
Number(task.status) === 1
|
||||
? style["active"]
|
||||
: style["inactive"]
|
||||
}`}
|
||||
>
|
||||
{Number(task.status) === 1 ? "进行中" : "已暂停"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["task-controls"]}>
|
||||
<label className={style["switch"]}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Number(task.status) === 1}
|
||||
onChange={() =>
|
||||
toggleTaskStatus(task.id, Number(task.status))
|
||||
}
|
||||
/>
|
||||
<span className={style["slider"]}></span>
|
||||
</label>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-info"]}>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>执行设备:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.deviceCount} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>目标人群:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.targetGroup}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>点赞间隔:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.likeInterval} 秒
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>每日上限:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.maxLikesPerDay} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-stats"]}>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["blue"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>今日点赞:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.lastLikeTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["green"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>总点赞数:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.totalLikeCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["auto-like-page"]}>{content}</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface LikeTask {
|
||||
todayLikeCount: number;
|
||||
totalLikeCount: number;
|
||||
updateTime: string;
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType?: number;
|
||||
}
|
||||
|
||||
// 创建任务数据
|
||||
@@ -81,12 +83,15 @@ export interface CreateLikeTaskData {
|
||||
contentTypes: ContentType[];
|
||||
deviceGroups: number[];
|
||||
deviceGroupsOptions: DeviceSelectionItem[];
|
||||
friendsGroups: number[];
|
||||
friendsGroupsOptions: FriendSelectionItem[];
|
||||
// 实际使用的好友字段(兼容旧字段)
|
||||
wechatFriends?: number[];
|
||||
wechatFriendsOptions?: FriendSelectionItem[];
|
||||
friendMaxLikes: number;
|
||||
friendTags?: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Switch, Spin, message } from "antd";
|
||||
import { Button, Input, Switch, Spin, message, Radio } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
fetchAutoLikeTaskDetail,
|
||||
} from "./api";
|
||||
import { CreateLikeTaskData, ContentType } from "./data";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import style from "./new.module.scss";
|
||||
|
||||
const contentTypeLabels: Record<ContentType, string> = {
|
||||
@@ -32,11 +33,15 @@ const NewAutoLike: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||
const [selectAllFriends, setSelectAllFriends] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
||||
planType: 1, // 默认独立计划
|
||||
name: "",
|
||||
interval: 5,
|
||||
maxLikes: 200,
|
||||
@@ -45,8 +50,8 @@ const NewAutoLike: React.FC = () => {
|
||||
contentTypes: ["text", "image", "video"],
|
||||
deviceGroups: [],
|
||||
deviceGroupsOptions: [],
|
||||
friendsGroups: [],
|
||||
friendsGroupsOptions: [],
|
||||
wechatFriends: [],
|
||||
wechatFriendsOptions: [],
|
||||
targetTags: [],
|
||||
friendMaxLikes: 10,
|
||||
enableFriendTags: false,
|
||||
@@ -66,6 +71,7 @@ const NewAutoLike: React.FC = () => {
|
||||
if (taskDetail) {
|
||||
const config = (taskDetail as any).config || taskDetail;
|
||||
setFormData({
|
||||
planType: config.planType ?? (taskDetail as any).planType ?? 1,
|
||||
name: taskDetail.name || "",
|
||||
interval: config.likeInterval || config.interval || 5,
|
||||
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
||||
@@ -74,8 +80,8 @@ const NewAutoLike: React.FC = () => {
|
||||
contentTypes: config.contentTypes || ["text", "image", "video"],
|
||||
deviceGroups: config.deviceGroups || [],
|
||||
deviceGroupsOptions: config.deviceGroupsOptions || [],
|
||||
friendsGroups: config.friendsgroups || [],
|
||||
friendsGroupsOptions: config.friendsGroupsOptions || [],
|
||||
wechatFriends: config.wechatFriends || [],
|
||||
wechatFriendsOptions: config.wechatFriendsOptions || [],
|
||||
targetTags: config.targetTags || [],
|
||||
friendMaxLikes: config.friendMaxLikes || 10,
|
||||
enableFriendTags: config.enableFriendTags || false,
|
||||
@@ -85,6 +91,10 @@ const NewAutoLike: React.FC = () => {
|
||||
(taskDetail as any).status === 1 ||
|
||||
(taskDetail as any).status === "running",
|
||||
);
|
||||
// 如果 wechatFriends 为空或未设置,可能表示选择了全部好友
|
||||
setSelectAllFriends(
|
||||
!config.wechatFriends || config.wechatFriends.length === 0
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("获取任务详情失败");
|
||||
@@ -127,11 +137,19 @@ const NewAutoLike: React.FC = () => {
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 如果选择了全部好友,提交时传空数组或特殊标识
|
||||
const submitData = {
|
||||
...formData,
|
||||
wechatFriends: selectAllFriends ? [] : formData.wechatFriends,
|
||||
wechatFriendsOptions: selectAllFriends ? [] : formData.wechatFriendsOptions,
|
||||
selectAllFriends: selectAllFriends, // 添加标识字段
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
await updateAutoLikeTask({ ...formData, id });
|
||||
await updateAutoLikeTask({ ...submitData, id });
|
||||
message.success("更新成功");
|
||||
} else {
|
||||
await createAutoLikeTask(formData);
|
||||
await createAutoLikeTask(submitData);
|
||||
message.success("创建成功");
|
||||
}
|
||||
navigate("/workspace/auto-like");
|
||||
@@ -142,6 +160,28 @@ const NewAutoLike: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 选择全部好友(仅设置标识)
|
||||
const handleSelectAllFriends = () => {
|
||||
if (!formData.deviceGroups || formData.deviceGroups.length === 0) {
|
||||
message.warning("请先选择执行设备");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectAllFriends) {
|
||||
// 取消全选标识
|
||||
setSelectAllFriends(false);
|
||||
// 清空已选好友
|
||||
handleUpdateFormData({
|
||||
wechatFriends: [],
|
||||
wechatFriendsOptions: [],
|
||||
});
|
||||
} else {
|
||||
// 设置全选标识
|
||||
setSelectAllFriends(true);
|
||||
message.success("已标记为选择全部好友");
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤器
|
||||
const renderStepIndicator = () => (
|
||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||
@@ -149,7 +189,22 @@ const NewAutoLike: React.FC = () => {
|
||||
|
||||
// 步骤1:基础设置
|
||||
const renderBasicSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.container}>
|
||||
{/* 计划类型和任务名称 */}
|
||||
<div className={style.card}>
|
||||
{isAdmin && (
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>计划类型</div>
|
||||
<Radio.Group
|
||||
value={formData.planType}
|
||||
onChange={e => handleUpdateFormData({ planType: e.target.value })}
|
||||
className={style.radioGroup}
|
||||
>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
@@ -159,6 +214,10 @@ const NewAutoLike: React.FC = () => {
|
||||
className={style.input}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点赞间隔 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞间隔</div>
|
||||
<div className={style.counterRow}>
|
||||
@@ -196,6 +255,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>设置两次点赞之间的最小时间间隔</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每日最大点赞数 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>每日最大点赞数</div>
|
||||
<div className={style.counterRow}>
|
||||
@@ -233,6 +296,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天最多点赞的次数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点赞时间范围 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞时间范围</div>
|
||||
<div className={style.timeRow}>
|
||||
@@ -252,6 +319,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天可以点赞的时间段</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点赞内容类型 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞内容类型</div>
|
||||
<div className={style.contentTypes}>
|
||||
@@ -276,6 +347,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>选择要点赞的内容类型</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 好友标签和自动开启 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>启用好友标签</span>
|
||||
@@ -288,7 +363,7 @@ const NewAutoLike: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<div className={style.formItem}>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Input
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags}
|
||||
@@ -311,6 +386,8 @@ const NewAutoLike: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
@@ -326,7 +403,8 @@ const NewAutoLike: React.FC = () => {
|
||||
|
||||
// 步骤2:设备选择
|
||||
const renderDeviceSelection = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.container}>
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
@@ -340,11 +418,13 @@ const NewAutoLike: React.FC = () => {
|
||||
showSelectedList={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, marginTop: 16 }}>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
@@ -354,32 +434,60 @@ const NewAutoLike: React.FC = () => {
|
||||
className={style.nextBtn}
|
||||
size="large"
|
||||
disabled={formData.deviceGroups.length === 0}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤3:好友设置
|
||||
const renderFriendSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.container}>
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<FriendSelection
|
||||
selectedOptions={formData.friendsGroupsOptions || []}
|
||||
onSelect={friends =>
|
||||
handleUpdateFormData({
|
||||
friendsGroups: friends.map(f => f.id),
|
||||
friendsGroupsOptions: friends,
|
||||
})
|
||||
}
|
||||
deviceIds={formData.deviceGroups}
|
||||
/>
|
||||
<div className={style.friendSelectionHeader}>
|
||||
<div className={style.formLabel}>选择好友</div>
|
||||
<Button
|
||||
type={selectAllFriends ? "primary" : "default"}
|
||||
size="small"
|
||||
onClick={handleSelectAllFriends}
|
||||
disabled={!formData.deviceGroups || formData.deviceGroups.length === 0}
|
||||
className={style.selectAllBtn}
|
||||
>
|
||||
{selectAllFriends ? "已选择全部" : "选择全部好友"}
|
||||
</Button>
|
||||
</div>
|
||||
{selectAllFriends ? (
|
||||
<div className={style.selectAllTip}>
|
||||
<span className={style.selectAllIcon}>✓</span>
|
||||
已标记为选择全部好友
|
||||
</div>
|
||||
) : (
|
||||
<FriendSelection
|
||||
selectedOptions={formData.wechatFriendsOptions || []}
|
||||
onSelect={friends => {
|
||||
handleUpdateFormData({
|
||||
wechatFriends: friends.map(f => f.id),
|
||||
wechatFriendsOptions: friends,
|
||||
});
|
||||
// 如果手动选择了好友,取消全选标识
|
||||
if (selectAllFriends) {
|
||||
setSelectAllFriends(false);
|
||||
}
|
||||
}}
|
||||
deviceIds={formData.deviceGroups}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, marginTop: 16 }}>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
@@ -390,11 +498,14 @@ const NewAutoLike: React.FC = () => {
|
||||
size="large"
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
!formData.friendsGroups || formData.friendsGroups.length === 0
|
||||
!selectAllFriends &&
|
||||
(!formData.wechatFriends || formData.wechatFriends.length === 0)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{isEditMode ? "更新任务" : "创建任务"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
.formBg {
|
||||
background: #f8f6f3;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 80px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
@@ -26,10 +55,31 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
@@ -126,6 +176,13 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contentTypeBtn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.contentTypeTag {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
@@ -230,3 +287,50 @@
|
||||
font-size: 15px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.friendSelectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.selectAllBtn {
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.selectAllTip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 8px;
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.selectAllIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mainBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function AutoLikeRecord() {
|
||||
</div>
|
||||
<Badge
|
||||
className={styles.timeBadge}
|
||||
count={formatDate(record.momentTime || record.likeTime)}
|
||||
count={formatDate(record.likeTime || record.momentTime)}
|
||||
style={{
|
||||
background: "#e8f0fe",
|
||||
color: "#333",
|
||||
|
||||
@@ -56,7 +56,7 @@ export function fetchImportRecords(
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||
return request(
|
||||
"/v1/workbench/import-records",
|
||||
"/v1/workbench/import-contact",
|
||||
{
|
||||
workbenchId,
|
||||
page,
|
||||
|
||||
@@ -1,11 +1,58 @@
|
||||
.formBg {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 24px 16px 0 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
// 输入框样式
|
||||
:global(.ant-input) {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.formItem {
|
||||
@@ -20,6 +67,12 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, message, TimePicker, Select, Switch } from "antd";
|
||||
import { Button, Input, message, TimePicker, Select, Switch, Radio, Card } from "antd";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
@@ -16,14 +16,18 @@ import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import style from "./index.module.scss";
|
||||
import dayjs from "dayjs";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
const ContactImportForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const isEdit = !!id;
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
planType: 1, // 计划类型:0-全局计划,1-独立计划
|
||||
name: "", // 任务名称
|
||||
status: 1, // 是否启用,默认启用
|
||||
type: 6, // 任务类型,固定为6
|
||||
@@ -89,6 +93,7 @@ const ContactImportForm: React.FC = () => {
|
||||
endTime: config.endTime ? dayjs(config.endTime, "HH:mm") : null,
|
||||
deviceGroupsOptions,
|
||||
poolGroupsOptions,
|
||||
planType: config.planType ?? (data as any).planType ?? 1,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -146,6 +151,7 @@ const ContactImportForm: React.FC = () => {
|
||||
remark: formData.remark || null,
|
||||
startTime: formData.startTime?.format("HH:mm") || null,
|
||||
endTime: formData.endTime?.format("HH:mm") || null,
|
||||
planType: (formData as any).planType ?? 1,
|
||||
};
|
||||
|
||||
if (isEdit && id) {
|
||||
@@ -220,158 +226,191 @@ const ContactImportForm: React.FC = () => {
|
||||
>
|
||||
<div className={style.formBg}>
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={e => handleUpdateFormData({ name: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>为此导入任务设置一个名称</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>设备选择</div>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
onSelect={handleDeviceSelect}
|
||||
placeholder="请选择设备"
|
||||
className={style.deviceSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要分配联系人的设备</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>流量池选择</div>
|
||||
<PoolSelection
|
||||
selectedOptions={formData.poolGroupsOptions}
|
||||
onSelect={handlePoolSelect}
|
||||
placeholder="请选择流量池"
|
||||
className={style.poolSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要导入的流量池</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>分配数量</div>
|
||||
<div className={style.stepperContainer}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ num: Math.max(1, formData.num - 1) })
|
||||
}
|
||||
disabled={formData.num <= 1}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
<Input
|
||||
value={formData.num}
|
||||
onChange={e => {
|
||||
const value = parseInt(e.target.value) || 1;
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, Math.max(1, value)),
|
||||
});
|
||||
}}
|
||||
className={style.stepperInput}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, formData.num + 1),
|
||||
})
|
||||
}
|
||||
disabled={formData.num >= 1000}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>要分配给设备的联系人数量</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>清除现有联系人</div>
|
||||
<Switch
|
||||
checked={formData.clearContact === 1}
|
||||
onChange={checked =>
|
||||
handleUpdateFormData({ clearContact: checked ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
<div className={style.counterTip}>是否清除设备上现有的联系人</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>备注类型</div>
|
||||
<Select
|
||||
placeholder="请选择备注类型"
|
||||
value={formData.remarkType}
|
||||
onChange={value => handleUpdateFormData({ remarkType: value })}
|
||||
className={style.select}
|
||||
>
|
||||
<Select.Option value={0}>不备注</Select.Option>
|
||||
<Select.Option value={1}>年月日</Select.Option>
|
||||
<Select.Option value={2}>月日</Select.Option>
|
||||
<Select.Option value={3}>自定义</Select.Option>
|
||||
</Select>
|
||||
<div className={style.counterTip}>选择联系人备注的格式</div>
|
||||
</div>
|
||||
|
||||
{formData.remarkType === 3 && (
|
||||
{/* 计划类型和任务名称 */}
|
||||
<Card className={style.card}>
|
||||
{isAdmin && (
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>计划类型</div>
|
||||
<Radio.Group
|
||||
value={(formData as any).planType}
|
||||
onChange={e =>
|
||||
handleUpdateFormData({ planType: e.target.value })
|
||||
}
|
||||
className={style.radioGroup}
|
||||
>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>自定义备注</div>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
placeholder="请输入备注内容"
|
||||
value={formData.remark}
|
||||
onChange={e => handleUpdateFormData({ remark: e.target.value })}
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={e => handleUpdateFormData({ name: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>输入自定义的备注内容</div>
|
||||
<div className={style.counterTip}>为此导入任务设置一个名称</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>开始时间</div>
|
||||
<TimePicker
|
||||
value={formData.startTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
startTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择开始时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天开始导入的时间</div>
|
||||
</div>
|
||||
{/* 设备选择 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>设备选择</div>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
onSelect={handleDeviceSelect}
|
||||
placeholder="请选择设备"
|
||||
className={style.deviceSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要分配联系人的设备</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>结束时间</div>
|
||||
<TimePicker
|
||||
value={formData.endTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
endTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择结束时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天结束导入的时间</div>
|
||||
</div>
|
||||
{/* 流量池选择 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>流量池选择</div>
|
||||
<PoolSelection
|
||||
selectedOptions={formData.poolGroupsOptions}
|
||||
onSelect={handlePoolSelect}
|
||||
placeholder="请选择流量池"
|
||||
className={style.poolSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要导入的流量池</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
className={style.formItem}
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<span>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.status === 1}
|
||||
onChange={check =>
|
||||
handleUpdateFormData({ status: check ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* 分配数量 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>分配数量</div>
|
||||
<div className={style.stepperContainer}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ num: Math.max(1, formData.num - 1) })
|
||||
}
|
||||
disabled={formData.num <= 1}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
<Input
|
||||
value={formData.num}
|
||||
onChange={e => {
|
||||
const value = parseInt(e.target.value) || 1;
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, Math.max(1, value)),
|
||||
});
|
||||
}}
|
||||
className={style.stepperInput}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, formData.num + 1),
|
||||
})
|
||||
}
|
||||
disabled={formData.num >= 1000}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>要分配给设备的联系人数量</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 清除现有联系人和备注类型 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>清除现有联系人</div>
|
||||
<Switch
|
||||
checked={formData.clearContact === 1}
|
||||
onChange={checked =>
|
||||
handleUpdateFormData({ clearContact: checked ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
<div className={style.counterTip}>是否清除设备上现有的联系人</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>备注类型</div>
|
||||
<Select
|
||||
placeholder="请选择备注类型"
|
||||
value={formData.remarkType}
|
||||
onChange={value => handleUpdateFormData({ remarkType: value })}
|
||||
className={style.select}
|
||||
>
|
||||
<Select.Option value={0}>不备注</Select.Option>
|
||||
<Select.Option value={1}>年月日</Select.Option>
|
||||
<Select.Option value={2}>月日</Select.Option>
|
||||
<Select.Option value={3}>自定义</Select.Option>
|
||||
</Select>
|
||||
<div className={style.counterTip}>选择联系人备注的格式</div>
|
||||
</div>
|
||||
{formData.remarkType === 3 && (
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>自定义备注</div>
|
||||
<Input
|
||||
placeholder="请输入备注内容"
|
||||
value={formData.remark}
|
||||
onChange={e => handleUpdateFormData({ remark: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>输入自定义的备注内容</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 时间设置 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>开始时间</div>
|
||||
<TimePicker
|
||||
value={formData.startTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
startTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择开始时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天开始导入的时间</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>结束时间</div>
|
||||
<TimePicker
|
||||
value={formData.endTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
endTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择结束时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天结束导入的时间</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 是否启用 */}
|
||||
<Card className={style.card}>
|
||||
<div
|
||||
className={style.formItem}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<span className={style.formLabel}>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.status === 1}
|
||||
onChange={check =>
|
||||
handleUpdateFormData({ status: check ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function fetchImportRecords(
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||
return request(
|
||||
"/v1/workbench/import-records",
|
||||
"/v1/workbench/import-contact",
|
||||
{
|
||||
workbenchId,
|
||||
page,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user