Compare commits
228 Commits
dev/full-e
...
main
Author | SHA1 | Date |
---|---|---|
|
84e9ada0e7 | 3 days ago |
|
6a3da2a537 | 3 days ago |
|
535aa38775 | 3 days ago |
|
22674fe498 | 3 days ago |
|
eabb53e0a9 | 3 days ago |
|
9ed85c81b2 | 4 days ago |
|
6baff26720 | 4 days ago |
|
0a06f6c16a | 4 days ago |
|
038db199e9 | 4 days ago |
|
210d3e7263 | 5 days ago |
|
945f1b3651 | 5 days ago |
|
378c864277 | 5 days ago |
|
cbf63c5e86 | 5 days ago |
|
01997b7d23 | 5 days ago |
|
d5a522e88f | 5 days ago |
|
e0950bb773 | 6 days ago |
|
74a0e0c14e | 6 days ago |
|
068a02ff64 | 6 days ago |
|
2abd149655 | 6 days ago |
|
2b344eec43 | 6 days ago |
|
7365ae08ee | 6 days ago |
|
5b5ebe896c | 6 days ago |
|
cd5dbb3be5 | 7 days ago |
|
d0fbf179fd | 1 week ago |
|
6b32601fc7 | 1 week ago |
|
403cb4f7c7 | 1 week ago |
|
c51df7fa03 | 1 week ago |
|
26b709a7dc | 1 week ago |
|
b29b927b5c | 1 week ago |
|
15acbf4f2f | 1 week ago |
|
8eccf74c61 | 1 week ago |
|
6211d275d1 | 1 week ago |
|
4fb5041179 | 1 week ago |
|
7ec91716a7 | 1 week ago |
|
2d7f269a27 | 1 week ago |
|
7e84b9cb5a | 2 weeks ago |
|
f78130544a | 2 weeks ago |
|
674b3cc591 | 2 weeks ago |
|
29cc138b33 | 2 weeks ago |
|
1977b8b404 | 2 weeks ago |
|
aabc409f6d | 2 weeks ago |
|
d9686b3ef5 | 2 weeks ago |
|
acf2c02063 | 2 weeks ago |
|
88958977c4 | 2 weeks ago |
|
812bf19c26 | 2 weeks ago |
|
3f9cc81b30 | 2 weeks ago |
|
701a6a00c0 | 2 weeks ago |
|
f8246c10a3 | 2 weeks ago |
|
95d6e7dd92 | 2 weeks ago |
|
ca5dcc0705 | 2 weeks ago |
|
70a7f25b07 | 2 weeks ago |
|
b0d72e0f7b | 2 weeks ago |
|
890027563c | 2 weeks ago |
|
f081aa46e9 | 2 weeks ago |
|
696832eba1 | 2 weeks ago |
|
b1db77bbe9 | 2 weeks ago |
|
afeef17e28 | 2 weeks ago |
|
be07ab175b | 2 weeks ago |
|
bf0f85b17a | 2 weeks ago |
|
7cd6e1b2aa | 2 weeks ago |
|
52e6307769 | 2 weeks ago |
|
2d4edd6c64 | 2 weeks ago |
|
318c2256b3 | 2 weeks ago |
|
5471345cf1 | 2 weeks ago |
|
f7f9500413 | 2 weeks ago |
|
95a1b16085 | 2 weeks ago |
|
e9e409ed0d | 2 weeks ago |
|
36f237a14a | 2 weeks ago |
|
d18b8fd5e5 | 2 weeks ago |
|
f9999e7d06 | 2 weeks ago |
|
49fede675e | 2 weeks ago |
|
df4932f325 | 2 weeks ago |
|
06209430da | 2 weeks ago |
|
db14ce64af | 2 weeks ago |
|
5f657b2618 | 2 weeks ago |
|
06a3e95e1a | 2 weeks ago |
|
095a8f3d3b | 2 weeks ago |
|
d293e3a1e5 | 2 weeks ago |
|
10e6b56446 | 3 weeks ago |
|
5d41b44270 | 3 weeks ago |
|
4dd404167b | 3 weeks ago |
|
17993f348c | 3 weeks ago |
|
645c85a59a | 3 weeks ago |
|
85c2622213 | 3 weeks ago |
|
cc471d93c8 | 3 weeks ago |
|
05a22161cd | 3 weeks ago |
|
aeb0672002 | 3 weeks ago |
|
1a0328303d | 3 weeks ago |
|
46fa96694f | 3 weeks ago |
|
fdfe4d3083 | 3 weeks ago |
|
e38d136cc4 | 3 weeks ago |
|
01f0f9bd9d | 3 weeks ago |
|
5ce589654a | 3 weeks ago |
|
7d7334ffe2 | 3 weeks ago |
|
ad1f934d3b | 3 weeks ago |
|
8f3fdef2e6 | 3 weeks ago |
|
e51581202d | 3 weeks ago |
|
d265f03a6e | 3 weeks ago |
|
7a45245437 | 3 weeks ago |
|
1393bf9899 | 3 weeks ago |
|
be2f85a0da | 3 weeks ago |
|
0529cce11b | 3 weeks ago |
|
fe3cde0c89 | 3 weeks ago |
|
0b37f1a9a0 | 3 weeks ago |
|
01dbcabdd7 | 3 weeks ago |
|
d55d55a3aa | 3 weeks ago |
|
4d4e6fe1d3 | 3 weeks ago |
|
e113c33fc6 | 3 weeks ago |
|
dd9e6e9e3a | 3 weeks ago |
|
d733303ec3 | 3 weeks ago |
|
e9d7bd1e8f | 3 weeks ago |
|
ef3e55eefb | 3 weeks ago |
|
c4dd6b0147 | 3 weeks ago |
|
af5dd4efdc | 3 weeks ago |
|
496861bcaa | 3 weeks ago |
|
1863983d02 | 3 weeks ago |
|
f1bc44da07 | 3 weeks ago |
|
12b793d277 | 3 weeks ago |
|
fdfd633ecf | 3 weeks ago |
|
d881778d78 | 3 weeks ago |
|
f4f956fd5e | 3 weeks ago |
|
c7d72d01f3 | 3 weeks ago |
|
a61ed7eb82 | 3 weeks ago |
|
0da3ea58af | 3 weeks ago |
|
1e9f84665e | 3 weeks ago |
|
0860a7054d | 3 weeks ago |
|
c622138c7d | 3 weeks ago |
|
d9082a1203 | 3 weeks ago |
|
3df24ab6ba | 3 weeks ago |
|
9a7db21d74 | 3 weeks ago |
|
ad142df232 | 3 weeks ago |
|
6db05883de | 3 weeks ago |
|
a6cb136ccf | 3 weeks ago |
|
716776f96b | 3 weeks ago |
|
74bd58529e | 3 weeks ago |
|
32fd97ce91 | 3 weeks ago |
|
62615cfb98 | 3 weeks ago |
|
1286a0c20a | 3 weeks ago |
|
efae99e81e | 3 weeks ago |
|
05de1791ed | 4 weeks ago |
|
1d513ec038 | 4 weeks ago |
|
c8ca19954f | 4 weeks ago |
|
9823214e4b | 4 weeks ago |
|
2a629df2ed | 4 weeks ago |
|
3519c4a414 | 4 weeks ago |
|
9d31e69db9 | 4 weeks ago |
|
9100e4b19d | 4 weeks ago |
|
f3ead963cc | 4 weeks ago |
|
36068565f9 | 4 weeks ago |
|
a778bc475f | 4 weeks ago |
|
ab3c763238 | 4 weeks ago |
|
6bc5faa3f8 | 4 weeks ago |
|
428524b232 | 4 weeks ago |
|
e4fc6f79c0 | 4 weeks ago |
|
f4a64f0a03 | 4 weeks ago |
|
25722eff77 | 4 weeks ago |
|
8b6326380d | 1 month ago |
|
b6903c7a9d | 1 month ago |
|
55e787b97f | 1 month ago |
|
dfb7016240 | 1 month ago |
|
0505313830 | 1 month ago |
|
99862de593 | 1 month ago |
|
e88fa38991 | 1 month ago |
|
04c71b1ff0 | 1 month ago |
|
1ea4dabbc2 | 1 month ago |
|
41948fed6f | 1 month ago |
|
ab36a85b22 | 1 month ago |
|
b1efa2f4f3 | 1 month ago |
|
92e5cab823 | 1 month ago |
|
ce8494fe26 | 1 month ago |
|
63ec5c0a28 | 1 month ago |
|
c51012f88e | 1 month ago |
|
fc87029d7a | 1 month ago |
|
8d7d5e32f4 | 1 month ago |
|
2ac04974a0 | 1 month ago |
|
df89722804 | 1 month ago |
|
5d73d04009 | 1 month ago |
|
c021cd162b | 1 month ago |
|
ba6e017ffd | 1 month ago |
|
b61c2baf9f | 1 month ago |
|
2b6b0f9961 | 1 month ago |
|
b6efb24a87 | 1 month ago |
|
7449ad9e44 | 1 month ago |
|
c2f94e3e81 | 1 month ago |
|
d1fff21159 | 1 month ago |
|
7b0bb05e89 | 1 month ago |
|
0d9dd3ad8c | 1 month ago |
|
3e8cda6700 | 1 month ago |
|
96daa64eb1 | 1 month ago |
|
e126dec2ca | 1 month ago |
|
c395ea1e8d | 1 month ago |
|
4d3c4979c8 | 1 month ago |
|
1dfd2f28e1 | 1 month ago |
|
1f972f417b | 1 month ago |
|
1696d13c31 | 1 month ago |
|
8277a7b8ac | 1 month ago |
|
5278dc6030 | 1 month ago |
|
c61795ea97 | 1 month ago |
|
a7d478b667 | 1 month ago |
|
569039c311 | 1 month ago |
|
769fa76831 | 1 month ago |
|
d7f1af1d49 | 1 month ago |
|
5446b7ca07 | 2 months ago |
|
4640801a53 | 2 months ago |
|
0b4e02cfe1 | 2 months ago |
|
2dd356ab3b | 2 months ago |
|
868a6441c9 | 2 months ago |
|
75132e14eb | 2 months ago |
|
98d885400f | 2 months ago |
|
29a605cc11 | 2 months ago |
|
54dac8e4ed | 2 months ago |
|
dcf86595fc | 2 months ago |
|
affd439f99 | 2 months ago |
|
b7fb0264f3 | 2 months ago |
|
86ae19598a | 2 months ago |
|
081815fb69 | 2 months ago |
|
50109ab629 | 2 months ago |
|
96d0d2bdc0 | 2 months ago |
|
6ce6ae1492 | 2 months ago |
|
15874bf229 | 2 months ago |
|
b7fa9490a7 | 2 months ago |
|
c9b44f233d | 2 months ago |
|
35ac6c6c48 | 2 months ago |
|
056d075a7c | 2 months ago |
|
4a9ea4311b | 2 months ago |
|
099edef821 | 2 months ago |
|
9933fa7460 | 2 months ago |
|
888cc8214a | 2 months ago |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 19.9967V14.9967H10V19.9967H19V12.9967H5V19.9967H8ZM4 10.9967H20V7.9967H14V3.9967H10V7.9967H4V10.9967ZM3 20.9967V12.9967H2V6.9967C2 6.44442 2.44772 5.9967 3 5.9967H8V2.9967C8 2.44442 8.44772 1.9967 9 1.9967H15C15.5523 1.9967 16 2.44442 16 2.9967V5.9967H21C21.5523 5.9967 22 6.44442 22 6.9967V12.9967H21V20.9967C21 21.549 20.5523 21.9967 20 21.9967H4C3.44772 21.9967 3 21.549 3 20.9967Z"></path></svg>
|
After Width: | Height: | Size: 491 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"></path></svg>
|
After Width: | Height: | Size: 555 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>
|
After Width: | Height: | Size: 640 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 20V7L20 3H4L2 7.00353V20C2 20.5523 2.44772 21 3 21H21C21.5523 21 22 20.5523 22 20ZM4 9H20V19H4V9ZM5.236 5H18.764L19.764 7H4.237L5.236 5ZM15 11H9V13H15V11Z"></path></svg>
|
After Width: | Height: | Size: 262 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966ZM15.6567 14.5113L19.1922 10.9758L12.8283 4.61185L9.29275 8.14738L15.6567 14.5113Z"></path></svg>
|
After Width: | Height: | Size: 442 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"></path></svg>
|
After Width: | Height: | Size: 555 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.6512 14.0654L11.6047 20H9.57389L10.9247 12.339L3.51465 4.92892L4.92886 3.51471L20.4852 19.0711L19.071 20.4853L12.6512 14.0654ZM11.7727 7.53009L12.0425 5.99999H10.2426L8.24257 3.99999H19.9999V5.99999H14.0733L13.4991 9.25652L11.7727 7.53009Z"></path></svg>
|
After Width: | Height: | Size: 347 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13.3414C21.3744 13.1203 20.7013 13 20 13C16.6863 13 14 15.6863 14 19C14 19.7013 14.1203 20.3744 14.3414 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13.3414ZM12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L19.6544 7.75616L18.3456 6.24384L12.0606 11.6829ZM21 18H24V20H21V23H19V20H16V18H19V15H21V18Z"></path></svg>
|
After Width: | Height: | Size: 460 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13H20V7.23792L12.0718 14.338L4 7.21594V19H14V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13ZM4.51146 5L12.0619 11.662L19.501 5H4.51146ZM21 18H24V20H21V23H19V20H16V18H19V15H21V18Z"></path></svg>
|
After Width: | Height: | Size: 328 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13.3414C21.3744 13.1203 20.7013 13 20 13C16.6863 13 14 15.6863 14 19C14 19.7013 14.1203 20.3744 14.3414 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13.3414ZM12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L19.6544 7.75616L18.3456 6.24384L12.0606 11.6829ZM19 22L15.4645 18.4645L16.8787 17.0503L19 19.1716L22.5355 15.636L23.9497 17.0503L19 22Z"></path></svg>
|
After Width: | Height: | Size: 504 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 14H20V7.23792L12.0718 14.338L4 7.21594V19H14V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V14ZM4.51146 5L12.0619 11.662L19.501 5H4.51146ZM19 22L15.4645 18.4645L16.8787 17.0503L19 19.1716L22.5355 15.636L23.9497 17.0503L19 22Z"></path></svg>
|
After Width: | Height: | Size: 372 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.8032 8.4928C19.4663 8.81764 20.2118 9 21 9C21.3425 9 21.6769 8.96557 22 8.89998V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H16.1C16.0344 3.32311 16 3.65753 16 4C16 5.23672 16.449 6.36857 17.1929 7.24142L12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L18.8032 8.4928ZM21 7C19.3431 7 18 5.65685 18 4C18 2.34315 19.3431 1 21 1C22.6569 1 24 2.34315 24 4C24 5.65685 22.6569 7 21 7Z"></path></svg>
|
After Width: | Height: | Size: 539 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.1 3C16.0344 3.32311 16 3.65753 16 4C16 4.34247 16.0344 4.67689 16.1 5H4.51146L12.0619 11.662L17.1098 7.14141C17.5363 7.66888 18.0679 8.10787 18.6728 8.42652L12.0718 14.338L4 7.21594V19H20V8.89998C20.3231 8.96557 20.6575 9 21 9C21.3425 9 21.6769 8.96557 22 8.89998V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H16.1ZM21 1C22.6569 1 24 2.34315 24 4C24 5.65685 22.6569 7 21 7C19.3431 7 18 5.65685 18 4C18 2.34315 19.3431 1 21 1Z"></path></svg>
|
After Width: | Height: | Size: 572 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>
|
After Width: | Height: | Size: 640 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6V21H11V6H5V4H19V6H13Z"></path></svg>
|
After Width: | Height: | Size: 130 B |
@ -0,0 +1,27 @@
|
|||||||
|
import { LexicalCommand, createCommand, TextFormatType } from 'lexical';
|
||||||
|
|
||||||
|
export interface CopiedFormat {
|
||||||
|
textFormatFlags: number; // 从node.getFormat()
|
||||||
|
style: string; // 从node.getStyle()
|
||||||
|
// todo: p 标签的样式
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivateFormatPainterPayload {
|
||||||
|
sticky: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// activate the format painter and copy the current selection's format
|
||||||
|
export const ACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand<ActivateFormatPainterPayload> =
|
||||||
|
createCommand('ACTIVATE_FORMAT_PAINTER_COMMAND');
|
||||||
|
|
||||||
|
// deactivate the format painter
|
||||||
|
export const DEACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand<void> =
|
||||||
|
createCommand('DEACTIVATE_FORMAT_PAINTER_COMMAND');
|
||||||
|
|
||||||
|
// dispatched by the plugin to inform UI about state changes
|
||||||
|
export interface FormatPainterState {
|
||||||
|
isActive: boolean;
|
||||||
|
isSticky: boolean;
|
||||||
|
}
|
||||||
|
export const FORMAT_PAINTER_STATE_UPDATE_COMMAND: LexicalCommand<FormatPainterState> =
|
||||||
|
createCommand('FORMAT_PAINTER_STATE_UPDATE_COMMAND');
|
@ -0,0 +1,86 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { $getSelection, $isRangeSelection, LexicalEditor, COMMAND_PRIORITY_NORMAL } from 'lexical';
|
||||||
|
import {
|
||||||
|
ACTIVATE_FORMAT_PAINTER_COMMAND,
|
||||||
|
DEACTIVATE_FORMAT_PAINTER_COMMAND,
|
||||||
|
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
|
||||||
|
FormatPainterState,
|
||||||
|
} from './FormatPainterCommands';
|
||||||
|
|
||||||
|
const PaintBrushIcon = () => <i className='format painter' />;
|
||||||
|
|
||||||
|
export function FormatPainterToolbarButton() {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
|
const [canCopy, setCanCopy] = useState(false);
|
||||||
|
|
||||||
|
// 插件状态
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerCommand<FormatPainterState>(
|
||||||
|
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
|
||||||
|
(payload) => {
|
||||||
|
setIsActive(payload.isActive);
|
||||||
|
setIsSticky(payload.isSticky);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_NORMAL,
|
||||||
|
);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
// 选区状态
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
editorState.read(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
|
||||||
|
setCanCopy(true);
|
||||||
|
} else {
|
||||||
|
setCanCopy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isActive) {
|
||||||
|
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
|
||||||
|
} else if (canCopy) {
|
||||||
|
editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: false });
|
||||||
|
}
|
||||||
|
// * !isActive and !canCopy 什么也不做
|
||||||
|
};
|
||||||
|
|
||||||
|
// 双击 保持激活
|
||||||
|
const handleDoubleClick = () => {
|
||||||
|
if (isActive && isSticky) {
|
||||||
|
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
|
||||||
|
} else if (canCopy) {
|
||||||
|
editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
className={`toolbar-item spaced ${isActive ? 'active' : ''}`}
|
||||||
|
// title={isActive ? (isSticky ? 'Format Painter (Sticky)' : 'Format Painter (Active)') : 'Format Painter'}
|
||||||
|
title={'格式刷'}
|
||||||
|
aria-label={isActive ? (isSticky ? 'Deactivate Format Painter (Sticky)' : 'Deactivate Format Painter (Active)') : 'Activate Format Painter'}
|
||||||
|
disabled={!isActive && !canCopy}
|
||||||
|
>
|
||||||
|
<PaintBrushIcon />
|
||||||
|
{/* <span style={{wordBreak: 'keep-all'}}>格式刷</span> */}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* <button type='button'
|
||||||
|
className={'toolbar-item spaced ' + (isActive ? 'active' : '')}
|
||||||
|
aria-label='Format Painter'>
|
||||||
|
<i className='format painter' />
|
||||||
|
</button> */}
|
||||||
|
export default FormatPainterToolbarButton;
|
@ -0,0 +1,267 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import {
|
||||||
|
$getSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
$isTextNode,
|
||||||
|
TextFormatType,
|
||||||
|
LexicalEditor,
|
||||||
|
COMMAND_PRIORITY_NORMAL,
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
} from 'lexical';
|
||||||
|
import { $patchStyleText, } from '@lexical/selection';
|
||||||
|
// $patchStyleText is more efficient for merging styles.
|
||||||
|
|
||||||
|
import {
|
||||||
|
CopiedFormat,
|
||||||
|
ACTIVATE_FORMAT_PAINTER_COMMAND,
|
||||||
|
DEACTIVATE_FORMAT_PAINTER_COMMAND,
|
||||||
|
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
|
||||||
|
ActivateFormatPainterPayload,
|
||||||
|
FormatPainterState,
|
||||||
|
} from './FormatPainterCommands';
|
||||||
|
|
||||||
|
// parse style string to object for $patchStyleText
|
||||||
|
function parseStyleText(style: string): Record<string, string> {
|
||||||
|
const styleObj: Record<string, string> = {};
|
||||||
|
style.split(';').forEach((rule) => {
|
||||||
|
const [key, value] = rule.split(':');
|
||||||
|
if (key && value && key.trim() && value.trim()) {
|
||||||
|
styleObj[key.trim()] = value.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return styleObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// map format flags to TextFormatType
|
||||||
|
const textFormatTypeMap: { flag: number; type: TextFormatType }[] = [
|
||||||
|
{ flag: 1, type: 'bold' },
|
||||||
|
{ flag: 2, type: 'italic' },
|
||||||
|
{ flag: 4, type: 'strikethrough' },
|
||||||
|
{ flag: 8, type: 'underline' },
|
||||||
|
{ flag: 16, type: 'code' },
|
||||||
|
{ flag: 32, type: 'subscript' },
|
||||||
|
{ flag: 64, type: 'superscript' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FormatPainterPlugin(): null {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
const [copiedFormat, setCopiedFormat] = useState<CopiedFormat | null>(null);
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
|
|
||||||
|
// 避免多次复制
|
||||||
|
const isPickingUpRef = useRef(false);
|
||||||
|
|
||||||
|
const broadcastState = useCallback(() => {
|
||||||
|
editor.dispatchCommand(FORMAT_PAINTER_STATE_UPDATE_COMMAND, {
|
||||||
|
isActive,
|
||||||
|
isSticky,
|
||||||
|
});
|
||||||
|
}, [editor, isActive, isSticky]);
|
||||||
|
|
||||||
|
// Update broadcast whenever state changes
|
||||||
|
useEffect(() => {
|
||||||
|
broadcastState();
|
||||||
|
}, [isActive, isSticky, broadcastState]);
|
||||||
|
|
||||||
|
|
||||||
|
// Activate Format Painter (Copy Format)
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerCommand<ActivateFormatPainterPayload>(
|
||||||
|
ACTIVATE_FORMAT_PAINTER_COMMAND,
|
||||||
|
(payload) => {
|
||||||
|
isPickingUpRef.current = true;
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
|
||||||
|
const anchorNode = selection.anchor.getNode();
|
||||||
|
let formatToCopy: CopiedFormat | null = null;
|
||||||
|
|
||||||
|
if ($isTextNode(anchorNode)) {
|
||||||
|
formatToCopy = {
|
||||||
|
textFormatFlags: anchorNode.getFormat(),
|
||||||
|
style: anchorNode.getStyle(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// ? todo: 从第一个字符获取格式
|
||||||
|
const nodes = selection.getNodes();
|
||||||
|
for (const node of nodes) {
|
||||||
|
if ($isTextNode(node)) {
|
||||||
|
formatToCopy = {
|
||||||
|
textFormatFlags: node.getFormat(),
|
||||||
|
style: node.getStyle(),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatToCopy) {
|
||||||
|
setCopiedFormat(formatToCopy);
|
||||||
|
setIsActive(true);
|
||||||
|
setIsSticky(payload.sticky);
|
||||||
|
// console.log('Format Painter Activated. Sticky:', payload.sticky, 'Format:', formatToCopy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 鼠标抬起
|
||||||
|
setTimeout(() => { isPickingUpRef.current = false; }, 50);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_NORMAL,
|
||||||
|
);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
// Deactivate Format Painter
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerCommand<void>(
|
||||||
|
DEACTIVATE_FORMAT_PAINTER_COMMAND,
|
||||||
|
() => {
|
||||||
|
if (!isActive) return false;
|
||||||
|
setIsActive(false);
|
||||||
|
setIsSticky(false);
|
||||||
|
// 不保留
|
||||||
|
setCopiedFormat(null);
|
||||||
|
// console.log('Format Painter Deactivated.');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_NORMAL,
|
||||||
|
);
|
||||||
|
}, [editor, isActive]);
|
||||||
|
|
||||||
|
|
||||||
|
// 应用复制的格式
|
||||||
|
const applyFormat = useCallback(() => {
|
||||||
|
if (!isActive || !copiedFormat || isPickingUpRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection) && copiedFormat) {
|
||||||
|
// console.log('copiedFormat:', copiedFormat, '\ntextFormatTypeMap:', textFormatTypeMap);
|
||||||
|
// TextNode (bold, italic, ...)
|
||||||
|
textFormatTypeMap.forEach(fmt => {
|
||||||
|
if (copiedFormat.textFormatFlags & fmt.flag) {
|
||||||
|
selection.formatText(fmt.type);
|
||||||
|
} else {
|
||||||
|
const currentSelection = $getSelection();
|
||||||
|
if ($isRangeSelection(currentSelection)) {
|
||||||
|
textFormatTypeMap.forEach(fmt => {
|
||||||
|
const shouldHaveFormat = (copiedFormat.textFormatFlags & fmt.flag) > 0;
|
||||||
|
if (currentSelection.hasFormat(fmt.type) !== shouldHaveFormat) {
|
||||||
|
currentSelection.formatText(fmt.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ensure applied
|
||||||
|
let newSelection = $getSelection();
|
||||||
|
if ($isRangeSelection(newSelection)) {
|
||||||
|
textFormatTypeMap.forEach(fmt => {
|
||||||
|
if (copiedFormat.textFormatFlags & fmt.flag) {
|
||||||
|
if (!newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type);
|
||||||
|
} else {
|
||||||
|
if (newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// inline styles (font-family, color, font-size, ...)
|
||||||
|
const stylesToApply = parseStyleText(copiedFormat.style);
|
||||||
|
// console.log('inline style', stylesToApply);
|
||||||
|
if (Object.keys(stylesToApply).length > 0) {
|
||||||
|
newSelection = $getSelection();
|
||||||
|
if ($isRangeSelection(newSelection)) {
|
||||||
|
$patchStyleText(newSelection as any, stylesToApply);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 清除格式
|
||||||
|
const selectedNodes = newSelection.getNodes();
|
||||||
|
selectedNodes.forEach(node => {
|
||||||
|
if ($isTextNode(node)) {
|
||||||
|
if (node.getStyle() !== "") {
|
||||||
|
node.setStyle(""); // 清除
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// todo: <p> node
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('Format Applied. Sticky:', isSticky);
|
||||||
|
|
||||||
|
if (!isSticky) {
|
||||||
|
setIsActive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [editor, isActive, isSticky, copiedFormat]);
|
||||||
|
|
||||||
|
|
||||||
|
// 鼠标抬起
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive || !copiedFormat) return;
|
||||||
|
|
||||||
|
const editorElement = editor.getRootElement();
|
||||||
|
if (!editorElement) return;
|
||||||
|
|
||||||
|
const handleMouseUp = (event: MouseEvent) => {
|
||||||
|
if (isPickingUpRef.current) return;
|
||||||
|
|
||||||
|
if (editorElement.contains(event.target as Node)) {
|
||||||
|
// todo: 改为在下一帧更新
|
||||||
|
setTimeout(() => {
|
||||||
|
const selection = editor.getEditorState().read($getSelection);
|
||||||
|
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
|
||||||
|
applyFormat();
|
||||||
|
} else if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
||||||
|
// 折叠的选区, 也应用
|
||||||
|
// applyFormat();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [editor, isActive, copiedFormat, applyFormat]);
|
||||||
|
|
||||||
|
|
||||||
|
// 按 esc 键取消格式
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [editor, isActive]);
|
||||||
|
|
||||||
|
// 鼠标样式
|
||||||
|
useEffect(() => {
|
||||||
|
const editorElement = editor.getRootElement();
|
||||||
|
if (editorElement) {
|
||||||
|
editorElement.style.cursor = isActive ? 'copy' : 'auto';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (editorElement) {
|
||||||
|
editorElement.style.cursor = 'auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [editor, isActive]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export default FormatPainterPlugin;
|
@ -0,0 +1,356 @@
|
|||||||
|
import {
|
||||||
|
WhatsAppOutlined,
|
||||||
|
FileAddOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
FieldNumberOutlined,
|
||||||
|
CompassOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { App, Flex, Select, Tooltip, Divider, Typography, Skeleton, Checkbox, Drawer, Button, Form, Input } from 'antd'
|
||||||
|
import { useOrderStore, fetchSetRemindStateAction, OrderLabelDefaultOptions, OrderStatusDefaultOptions, remindStatusOptions } from '@/stores/OrderStore'
|
||||||
|
import { copy, isEmpty } from '@/utils/commons'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import useConversationStore from '@/stores/ConversationStore'
|
||||||
|
import useAuthStore from '@/stores/AuthStore'
|
||||||
|
const OrderProfile = ({ coliSN, ...props }) => {
|
||||||
|
const { notification, message } = App.useApp()
|
||||||
|
const [formComment] = Form.useForm()
|
||||||
|
const [formWhatsApp] = Form.useForm()
|
||||||
|
const [formExtra] = Form.useForm()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const [openOrderCommnet, setOpenOrderCommnet] = useState(false)
|
||||||
|
const [openWhatsApp, setOpenWhatsApp] = useState(false)
|
||||||
|
const [openExtra, setOpenExtra] = useState(false)
|
||||||
|
|
||||||
|
const orderLabelOptions = copy(OrderLabelDefaultOptions)
|
||||||
|
orderLabelOptions.unshift({ value: 0, label: '未设置', disabled: true })
|
||||||
|
|
||||||
|
const orderStatusOptions = copy(OrderStatusDefaultOptions)
|
||||||
|
const [orderDetail, customerDetail, fetchOrderDetail, setOrderPropValue, appendOrderComment, updateWhatsapp, updateExtraInfo] = useOrderStore((s) => [
|
||||||
|
s.orderDetail,
|
||||||
|
s.customerDetail,
|
||||||
|
s.fetchOrderDetail,
|
||||||
|
s.setOrderPropValue,
|
||||||
|
s.appendOrderComment,
|
||||||
|
s.updateWhatsapp,
|
||||||
|
s.updateExtraInfo,
|
||||||
|
])
|
||||||
|
|
||||||
|
const loginUser = useAuthStore((state) => state.loginUser)
|
||||||
|
const currentOrder = useConversationStore(useShallow((state) => state.currentConversation?.coli_sn || ''))
|
||||||
|
const orderId = coliSN || currentOrder
|
||||||
|
|
||||||
|
const [orderRemindState, setOrderRemindState] = useState(orderDetail.remindstate)
|
||||||
|
useEffect(() => {
|
||||||
|
setOrderRemindState(orderDetail.remindstate)
|
||||||
|
}, [orderDetail.remindstate])
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderId) {
|
||||||
|
setLoading(true)
|
||||||
|
fetchOrderDetail(orderId)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
.catch((reason) => {
|
||||||
|
notification.error({
|
||||||
|
message: '查询出错',
|
||||||
|
description: reason.message,
|
||||||
|
placement: 'top',
|
||||||
|
duration: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return () => {}
|
||||||
|
}, [orderId])
|
||||||
|
|
||||||
|
const handleSetRemindState = async (checkedValue) => {
|
||||||
|
const state = checkedValue.filter((v) => v !== orderRemindState)
|
||||||
|
const oldState = orderRemindState
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEmpty(state)) {
|
||||||
|
setOrderRemindState(null)
|
||||||
|
} else {
|
||||||
|
setOrderRemindState(state[0])
|
||||||
|
}
|
||||||
|
await fetchSetRemindStateAction({ coli_sn: coliSN, remindstate: state })
|
||||||
|
message.success('设置成功')
|
||||||
|
} catch (error) {
|
||||||
|
notification.warning({ message: '设置失败', description: error.message, placement: 'top', duration: 60 })
|
||||||
|
setOrderRemindState(oldState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomerName = () => {
|
||||||
|
if (orderDetail.buytime > 0) return customerDetail.name + '(R' + orderDetail.buytime + ')'
|
||||||
|
return customerDetail.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlanStatus = () => {
|
||||||
|
return orderDetail.DidPlan === 0 ? '未做计划' : '已做计划'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Skeleton active loading={loading}>
|
||||||
|
<Flex gap='small' vertical={true} justify='space-between' className='p-2'>
|
||||||
|
<Typography.Text>
|
||||||
|
<FieldNumberOutlined className='pr-1' />
|
||||||
|
{orderDetail.order_no}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
<UserOutlined className=' pr-1' />
|
||||||
|
{getCustomerName()}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
<CompassOutlined className=' pr-1' />
|
||||||
|
{orderDetail.MEI_Country}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
<PhoneOutlined className=' pr-1' />
|
||||||
|
{customerDetail.phone}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
<MailOutlined className='pr-1' />
|
||||||
|
{customerDetail.email}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
<WhatsAppOutlined className='pr-1' />
|
||||||
|
{isEmpty(customerDetail.whatsapp_phone_number) ? (
|
||||||
|
<Button type='text' onClick={() => setOpenWhatsApp(true)} size='small'>
|
||||||
|
设置 WhatsApp
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link to={`/order/chat/${coliSN}`} state={orderDetail}>
|
||||||
|
{customerDetail.whatsapp_phone_number}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
<Tooltip title='出发日期'>
|
||||||
|
<CalendarOutlined className='pr-1' />
|
||||||
|
{orderDetail.COLI_OrderStartDate}
|
||||||
|
</Tooltip>
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
<Tooltip title='计划状态'>
|
||||||
|
<CheckOutlined className='pr-1' />
|
||||||
|
{getPlanStatus()}
|
||||||
|
</Tooltip>
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
<Divider orientation='left'>
|
||||||
|
<Typography.Text strong>订单状态</Typography.Text>
|
||||||
|
</Divider>
|
||||||
|
<Flex gap='small' vertical={false} justify='space-between'>
|
||||||
|
<Select
|
||||||
|
className={`[&_.ant-select-selection-item]:text-gray-950`}
|
||||||
|
key={'orderlabel'}
|
||||||
|
size='small'
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
variant='underlined'
|
||||||
|
onSelect={(value) => {
|
||||||
|
setOrderPropValue(coliSN, 'orderlabel', value)
|
||||||
|
.then(() => {
|
||||||
|
message.success('设置成功')
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
notification.error({
|
||||||
|
message: '设置出错',
|
||||||
|
description: reason.message,
|
||||||
|
placement: 'top',
|
||||||
|
duration: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
value={orderDetail.tags}
|
||||||
|
options={orderLabelOptions}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className={`[&_.ant-select-selection-item]:text-gray-950`}
|
||||||
|
key={'orderstatus'}
|
||||||
|
size='small'
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
variant='underlined'
|
||||||
|
onSelect={(value) => {
|
||||||
|
setOrderPropValue(coliSN, 'orderstatus', value)
|
||||||
|
.then(() => {
|
||||||
|
message.success('设置成功')
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
notification.error({
|
||||||
|
message: '设置出错',
|
||||||
|
description: reason.message,
|
||||||
|
placement: 'top',
|
||||||
|
duration: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
value={orderDetail.states}
|
||||||
|
options={orderStatusOptions}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Divider orientation='left'>
|
||||||
|
<Typography.Text strong>催信</Typography.Text>
|
||||||
|
</Divider>
|
||||||
|
<Checkbox.Group key='substatus' className='px-2' value={[orderRemindState]} options={remindStatusOptions} onChange={handleSetRemindState} />
|
||||||
|
|
||||||
|
<Divider orientation='left'>
|
||||||
|
<Typography.Text strong>表单信息</Typography.Text>
|
||||||
|
<Tooltip title='添加'>
|
||||||
|
<FileAddOutlined
|
||||||
|
className='pl-1'
|
||||||
|
onClick={() => {
|
||||||
|
setOpenOrderCommnet(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Divider>
|
||||||
|
<p className='p-2 overflow-auto m-0 break-words whitespace-pre-wrap' dangerouslySetInnerHTML={{ __html: orderDetail.order_detail }}></p>
|
||||||
|
|
||||||
|
<Divider orientation='left'>
|
||||||
|
<Typography.Text strong>特殊要求</Typography.Text>
|
||||||
|
</Divider>
|
||||||
|
<Typography.Text>{orderDetail.customer_request}</Typography.Text>
|
||||||
|
<Divider orientation='left'>
|
||||||
|
<Typography.Text strong>外联备注</Typography.Text>
|
||||||
|
{/* <Tooltip title='修改'>
|
||||||
|
<EditOutlined className='pl-1' />
|
||||||
|
</Tooltip> */}
|
||||||
|
</Divider>
|
||||||
|
<Typography.Text>{orderDetail.wl_memo}</Typography.Text>
|
||||||
|
|
||||||
|
<Divider orientation='left'>
|
||||||
|
<Typography.Text strong>附加信息</Typography.Text>
|
||||||
|
<Tooltip title='修改'>
|
||||||
|
<EditOutlined
|
||||||
|
className='pl-1'
|
||||||
|
onClick={() => {
|
||||||
|
formExtra.setFieldsValue({ extra: orderDetail.COLI_Introduction })
|
||||||
|
setOpenExtra(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Divider>
|
||||||
|
<Typography.Text>{orderDetail.COLI_Introduction}</Typography.Text>
|
||||||
|
</Skeleton>
|
||||||
|
<Drawer title='添加表单信息' closable={{ 'aria-label': 'Close Button' }} onClose={() => setOpenOrderCommnet(false)} open={openOrderCommnet}>
|
||||||
|
<Form
|
||||||
|
layout={'vertical'}
|
||||||
|
form={formComment}
|
||||||
|
initialValues={{ comment: '' }}
|
||||||
|
scrollToFirstError
|
||||||
|
onFinish={(values) => {
|
||||||
|
appendOrderComment(loginUser.userId, orderId, values.comment)
|
||||||
|
.then(() => {
|
||||||
|
notification.success({
|
||||||
|
message: '温性提示',
|
||||||
|
description: '添加表单信息成功',
|
||||||
|
})
|
||||||
|
setOpenOrderCommnet(false)
|
||||||
|
formComment.setFieldsValue({ comment: '' })
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
notification.error({
|
||||||
|
message: '添加出错',
|
||||||
|
description: reason.message,
|
||||||
|
placement: 'top',
|
||||||
|
duration: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<Form.Item name='comment' label='表单信息' rules={[{ required: true, message: '请输入表单信息' }]}>
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type='primary' htmlType='submit'>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
<Drawer title='设置 WhatsApp' closable={{ 'aria-label': 'Close Button' }} onClose={() => setOpenWhatsApp(false)} open={openWhatsApp}>
|
||||||
|
<Form
|
||||||
|
layout={'vertical'}
|
||||||
|
form={formWhatsApp}
|
||||||
|
initialValues={{ number: '' }}
|
||||||
|
scrollToFirstError
|
||||||
|
onFinish={(values) => {
|
||||||
|
updateWhatsapp(orderId, values.number)
|
||||||
|
.then(() => {
|
||||||
|
notification.success({
|
||||||
|
message: '温性提示',
|
||||||
|
description: '设置 WhatsApp 成功',
|
||||||
|
})
|
||||||
|
setOpenWhatsApp(false)
|
||||||
|
formWhatsApp.setFieldsValue({ number: '' })
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
notification.error({
|
||||||
|
message: '设置出错',
|
||||||
|
description: reason.message,
|
||||||
|
placement: 'top',
|
||||||
|
duration: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<Form.Item name='number' label='WhatsApp' rules={[{ required: true, message: '请输入 WhatsApp 号码' }]}>
|
||||||
|
<Input placeholder='国家代码+城市代码+电话号码' />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type='primary' htmlType='submit'>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
<Drawer title='设置附加信息' closable={{ 'aria-label': 'Close Button' }} onClose={() => setOpenExtra(false)} open={openExtra}>
|
||||||
|
<Form
|
||||||
|
layout={'vertical'}
|
||||||
|
form={formExtra}
|
||||||
|
scrollToFirstError
|
||||||
|
onFinish={(values) => {
|
||||||
|
updateExtraInfo(orderId, values.extra)
|
||||||
|
.then(() => {
|
||||||
|
notification.success({
|
||||||
|
message: '温性提示',
|
||||||
|
description: '设置附加信息成功',
|
||||||
|
})
|
||||||
|
setOpenExtra(false)
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
notification.error({
|
||||||
|
message: '设置出错',
|
||||||
|
description: reason.message,
|
||||||
|
placement: 'top',
|
||||||
|
duration: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<Form.Item name='extra' label='附加信息' rules={[{ required: true, message: '请输入附加信息' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type='primary' htmlType='submit'>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrderProfile
|
@ -0,0 +1,189 @@
|
|||||||
|
import { getEmailDirAction, getMailboxCountAction, getRootMailboxDirAction, getEmailChangesChannel, EMAIL_CHANNEL_NAME } from '@/actions/EmailActions'
|
||||||
|
import { buildTree, isEmpty, olog, sortArrayByOrder } from '@/utils/commons'
|
||||||
|
import { readIndexDB, writeIndexDB, createIndexedDBStore, clean7DaysMailboxLog } from '@/utils/indexedDB';
|
||||||
|
import { internalEventEmitter } from '@/utils/EventEmitterService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email
|
||||||
|
*/
|
||||||
|
const emailSlice = (set, get) => ({
|
||||||
|
emailMsg: { id: -1, conversationid: '', actionId: '', order_opi: '', coli_sn: '', msgOrigin: {} },
|
||||||
|
setEmailMsg: (emailMsg) => {
|
||||||
|
const { editorOpen } = get()
|
||||||
|
return editorOpen ? false : set({ emailMsg }) // 已经打开的不更新
|
||||||
|
},
|
||||||
|
detailPopupOpen: false,
|
||||||
|
setDetailOpen: (v) => set({ detailPopupOpen: v }),
|
||||||
|
openDetail: () => set(() => ({ detailPopupOpen: true })),
|
||||||
|
closeDetail: () => set(() => ({ detailPopupOpen: false })),
|
||||||
|
editorOpen: false,
|
||||||
|
setEditorOpen: (v) => set({ editorOpen: v }),
|
||||||
|
openEditor: () => set(() => ({ editorOpen: true })),
|
||||||
|
closeEditor: () => set(() => ({ editorOpen: false })),
|
||||||
|
|
||||||
|
// EmailEditorPopup 组件的 props
|
||||||
|
// @property {string} fromEmail - 发件人邮箱
|
||||||
|
// @property {string} fromUser - 发件人用户
|
||||||
|
// @property {string} fromOrder - 发件订单
|
||||||
|
// @property {string} toEmail - 收件人邮箱
|
||||||
|
// @property {string} conversationid - 会话ID
|
||||||
|
// @property {string} quoteid - 引用邮件ID
|
||||||
|
// @property {object} draft - 草稿
|
||||||
|
// @property {string} action - reply / forward / new / edit
|
||||||
|
// @property {string} oid - coli_sn
|
||||||
|
// @property {object} mailData - 邮件内容
|
||||||
|
// @property {string} receiverName - 收件人称呼
|
||||||
|
emailEdiorProps: new Map(),
|
||||||
|
setEditorProps: (v) => {
|
||||||
|
const { emailEdiorProps } = get()
|
||||||
|
const uniqueKey = v.quoteid || Date.now().toString(32)
|
||||||
|
const currentEditValue = { ...v, key: `${v.action}-${uniqueKey}` }
|
||||||
|
const news = new Map(emailEdiorProps).set(currentEditValue.key, currentEditValue)
|
||||||
|
for (const [key, value] of news.entries()) {
|
||||||
|
console.log(value)
|
||||||
|
}
|
||||||
|
return set((state) => ({ emailEdiorProps: news, currentEditKey: currentEditValue.key, currentEditValue }))
|
||||||
|
// return set((state) => ({ emailEdiorProps: { ...state.emailEdiorProps, ...v } }))
|
||||||
|
},
|
||||||
|
closeEditor1: (key) => {
|
||||||
|
const { emailEdiorProps } = get()
|
||||||
|
const newProps = new Map(emailEdiorProps)
|
||||||
|
newProps.delete(key)
|
||||||
|
return set(() => ({ emailEdiorProps: newProps }))
|
||||||
|
},
|
||||||
|
clearEditor: () => {
|
||||||
|
return set(() => ({ emailEdiorProps: new Map() }))
|
||||||
|
},
|
||||||
|
currentEditKey: '',
|
||||||
|
setCurrentEditKey: (key) => {
|
||||||
|
const { emailEdiorProps, setCurrentEditValue } = get()
|
||||||
|
const value = emailEdiorProps.get(key)
|
||||||
|
setCurrentEditValue(value)
|
||||||
|
return set(() => ({ currentEditKey: key }))
|
||||||
|
},
|
||||||
|
currentEditValue: {},
|
||||||
|
setCurrentEditValue: (v) => {
|
||||||
|
return set(() => ({ currentEditValue: v }))
|
||||||
|
},
|
||||||
|
|
||||||
|
// mailboxNestedDirs: new Map(),
|
||||||
|
// setMailboxNestedDirs: (opi, dirs) => {
|
||||||
|
// const { mailboxNestedDirs } = get()
|
||||||
|
// const news = mailboxNestedDirs.set(opi, dirs)
|
||||||
|
// return set(() => ({ mailboxNestedDirs: news }))
|
||||||
|
// },
|
||||||
|
|
||||||
|
currentMailboxDEI: 0,
|
||||||
|
setCurrentMailboxDEI: (id) => {
|
||||||
|
return set(() => ({ currentMailboxDEI: id }))
|
||||||
|
},
|
||||||
|
currentMailboxOPI: 0,
|
||||||
|
setCurrentMailboxOPI: (id) => {
|
||||||
|
return set(() => ({ currentMailboxOPI: id }))
|
||||||
|
},
|
||||||
|
|
||||||
|
mailboxNestedDirsActive: [],
|
||||||
|
setMailboxNestedDirsActive: (dir) => {
|
||||||
|
return set(() => ({ mailboxNestedDirsActive: dir }))
|
||||||
|
},
|
||||||
|
updateCurrentMailboxNestedDirs: (dirs) => {
|
||||||
|
const { mailboxNestedDirsActive } = get()
|
||||||
|
const _Map = new Map(mailboxNestedDirsActive.map((obj) => [obj.key, obj]))
|
||||||
|
dirs.forEach((row) => {
|
||||||
|
_Map.set(row.key, row)
|
||||||
|
})
|
||||||
|
// const _newValue = sortArrayByOrder(Array.from(_Map.values()), 'key', ['search-orders'])
|
||||||
|
const _newValue = Array.from(_Map.values())
|
||||||
|
|
||||||
|
return set(() => ({ mailboxNestedDirsActive: _newValue }))
|
||||||
|
},
|
||||||
|
|
||||||
|
mailboxActiveNode: {},
|
||||||
|
setMailboxActiveNode: (node) => {
|
||||||
|
return set(() => ({ mailboxActiveNode: node }))
|
||||||
|
},
|
||||||
|
|
||||||
|
mailboxList: [],
|
||||||
|
setMailboxList: (list) => {
|
||||||
|
return set(() => ({ mailboxList: list }))
|
||||||
|
},
|
||||||
|
mailboxActiveMAI: 0,
|
||||||
|
setMailboxActiveMAI: (mai) => {
|
||||||
|
return set(() => ({ mailboxActiveMAI: mai }))
|
||||||
|
},
|
||||||
|
mailboxActiveCOLI: 0,
|
||||||
|
setMailboxActiveCOLI: (coli) => {
|
||||||
|
return set(() => ({ mailboxActiveCOLI: coli }))
|
||||||
|
},
|
||||||
|
|
||||||
|
getOPIEmailDir: async (opi_sn = 0, userIdStr = '', refreshNow = false) => {
|
||||||
|
// console.log('🌐requesting opi dir', opi_sn, typeof opi_sn)
|
||||||
|
const { setMailboxNestedDirsActive, updateMailboxCount } = get()
|
||||||
|
const readCache = await readIndexDB(Number(opi_sn), 'dirs', 'mailbox')
|
||||||
|
// console.log(readCache);
|
||||||
|
let isNeedRefresh = refreshNow
|
||||||
|
if (!isEmpty(readCache)) {
|
||||||
|
setMailboxNestedDirsActive(readCache?.tree || [])
|
||||||
|
isNeedRefresh = refreshNow || Date.now() - readCache.treeTimestamp > 1 * 60 * 60 * 1000
|
||||||
|
// isNeedRefresh = true; // test: 0
|
||||||
|
}
|
||||||
|
if (isEmpty(readCache) || isNeedRefresh) {
|
||||||
|
// > {4} 更新
|
||||||
|
const rootTree = await getRootMailboxDirAction({ opi_sn, userIdStr: String(userIdStr || opi_sn) })
|
||||||
|
// console.log('empty', opi_sn, userIdStr, isEmpty(readCache), isNeedRefresh, rootTree);
|
||||||
|
setMailboxNestedDirsActive(rootTree)
|
||||||
|
} else {
|
||||||
|
// 只更新数量
|
||||||
|
updateMailboxCount({ opi_sn })
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新数量
|
||||||
|
* @usage 1. 邮件列表页切换用户时
|
||||||
|
* @usage 2. 收到新邮件推送时
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
updateMailboxCount: async ({ opi_sn }) => {
|
||||||
|
// const { setMailboxNestedDirsActive } = get()
|
||||||
|
await getMailboxCountAction({ opi_sn })
|
||||||
|
// const readCache = await readIndexDB(Number(opi_sn), 'dirs', 'mailbox')
|
||||||
|
// if (!isEmpty(readCache)) {
|
||||||
|
// setMailboxNestedDirsActive(readCache?.tree || [])
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
|
||||||
|
async initMailbox({ opi_sn, dei_sn, userIdStr }) {
|
||||||
|
olog('Initialize Mailbox ---- ')
|
||||||
|
const { currentMailboxOPI, setCurrentMailboxOPI, setCurrentMailboxDEI, getOPIEmailDir, setMailboxNestedDirsActive, } = get()
|
||||||
|
createIndexedDBStore(['dirs', 'maillist', 'listrow', 'mailinfo', 'draft'], 'mailbox')
|
||||||
|
setCurrentMailboxOPI(opi_sn)
|
||||||
|
setCurrentMailboxDEI(dei_sn)
|
||||||
|
getOPIEmailDir(opi_sn, userIdStr, true)
|
||||||
|
|
||||||
|
// --- Setup Internal Event Listener ---
|
||||||
|
internalEventEmitter.on(EMAIL_CHANNEL_NAME, async (event) => {
|
||||||
|
// console.log(`🔔Received internal event. `, event.detail)
|
||||||
|
if (event.detail && event.detail.type === 'dirs') {
|
||||||
|
const readCache = await readIndexDB(event.detail.key, 'dirs', 'mailbox')
|
||||||
|
if (!isEmpty(readCache)) {
|
||||||
|
setMailboxNestedDirsActive(readCache?.tree || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// --- Setup BroadcastChannel Listener ---
|
||||||
|
const channel = getEmailChangesChannel()
|
||||||
|
channel.addEventListener('message', async (event) => {
|
||||||
|
// console.log(`📣Received channel event. `, event.data)
|
||||||
|
if (event.data.type === 'dirs' && currentMailboxOPI === event.data.key) {
|
||||||
|
const readCache = await readIndexDB(event.data.key, 'dirs', 'mailbox')
|
||||||
|
if (!isEmpty(readCache)) {
|
||||||
|
setMailboxNestedDirsActive(readCache?.tree || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default emailSlice
|
@ -0,0 +1,20 @@
|
|||||||
|
class EventEmitterService extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
// console.log('Internal EventEmitterService created.'); // For debugging
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventName, detail) {
|
||||||
|
this.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventName, handler) {
|
||||||
|
this.addEventListener(eventName, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(eventName, handler) {
|
||||||
|
this.removeEventListener(eventName, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const internalEventEmitter = new EventEmitterService();
|
@ -0,0 +1,570 @@
|
|||||||
|
import { isEmpty } from './commons';
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const INDEXED_DB_VERSION = 4;
|
||||||
|
export const logWebsocket = (message, direction) => {
|
||||||
|
var open = indexedDB.open('LogWebsocketData', INDEXED_DB_VERSION)
|
||||||
|
open.onupgradeneeded = function () {
|
||||||
|
var db = open.result
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains('LogStore')) {
|
||||||
|
var store = db.createObjectStore('LogStore', { keyPath: 'id', autoIncrement: true })
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
} else {
|
||||||
|
const logStore = open.transaction.objectStore('LogStore')
|
||||||
|
if (!logStore.indexNames.contains('timestamp')) {
|
||||||
|
logStore.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open.onsuccess = function () {
|
||||||
|
var db = open.result
|
||||||
|
var tx = db.transaction('LogStore', 'readwrite')
|
||||||
|
var store = tx.objectStore('LogStore')
|
||||||
|
store.put({ direction, message, _date: new Date().toLocaleString(), timestamp: Date.now() })
|
||||||
|
tx.oncomplete = function () {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const readWebsocketLog = (limit = 20) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let openRequest = indexedDB.open('LogWebsocketData')
|
||||||
|
openRequest.onupgradeneeded = function () {
|
||||||
|
var db = openRequest.result
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains('LogStore')) {
|
||||||
|
var store = db.createObjectStore('LogStore', { keyPath: 'id', autoIncrement: true })
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
} else {
|
||||||
|
const logStore = openRequest.transaction.objectStore('LogStore')
|
||||||
|
if (!logStore.indexNames.contains('timestamp')) {
|
||||||
|
logStore.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openRequest.onerror = function (e) {
|
||||||
|
reject('Error opening database.')
|
||||||
|
}
|
||||||
|
openRequest.onsuccess = function (e) {
|
||||||
|
let db = e.target.result
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains('LogStore')) {
|
||||||
|
resolve('Database does not exist.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let transaction = db.transaction('LogStore', 'readonly')
|
||||||
|
let store = transaction.objectStore('LogStore')
|
||||||
|
const request = store.openCursor(null, 'prev'); // 从后往前
|
||||||
|
const results = [];
|
||||||
|
let count = 0;
|
||||||
|
request.onerror = function (e) {
|
||||||
|
reject('Error getting records.')
|
||||||
|
}
|
||||||
|
request.onsuccess = function (e) {
|
||||||
|
const cursor = e.target.result
|
||||||
|
if (cursor) {
|
||||||
|
if (count < limit) {
|
||||||
|
results.unshift(cursor.value)
|
||||||
|
count++
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(results))
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(results))
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
export const clearWebsocketLog = () => {
|
||||||
|
let openRequest = indexedDB.open('LogWebsocketData')
|
||||||
|
openRequest.onerror = function (e) {}
|
||||||
|
openRequest.onsuccess = function (e) {
|
||||||
|
let db = e.target.result
|
||||||
|
if (!db.objectStoreNames.contains('LogStore')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let transaction = db.transaction('LogStore', 'readwrite')
|
||||||
|
let store = transaction.objectStore('LogStore')
|
||||||
|
// Clear the store
|
||||||
|
let clearRequest = store.clear()
|
||||||
|
clearRequest.onerror = function (e) {}
|
||||||
|
clearRequest.onsuccess = function (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createIndexedDBStore = (tables, database) => {
|
||||||
|
var open = indexedDB.open(database, INDEXED_DB_VERSION)
|
||||||
|
open.onupgradeneeded = function () {
|
||||||
|
// console.log('readIndexDB onupgradeneeded', database, )
|
||||||
|
var db = open.result
|
||||||
|
// 数据库是否存在
|
||||||
|
for (const table of tables) {
|
||||||
|
if (!db.objectStoreNames.contains(table)) {
|
||||||
|
var store = db.createObjectStore(table, { keyPath: 'key' })
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
} else {
|
||||||
|
const objectStore = open.transaction.objectStore(table)
|
||||||
|
if (!objectStore.indexNames.contains('timestamp')) {
|
||||||
|
objectStore.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeIndexDB = (rows, table, database) => {
|
||||||
|
var open = indexedDB.open(database, INDEXED_DB_VERSION)
|
||||||
|
open.onupgradeneeded = function () {
|
||||||
|
// console.log('readIndexDB onupgradeneeded', table, )
|
||||||
|
var db = open.result
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains(table)) {
|
||||||
|
var store = db.createObjectStore(table, { keyPath: 'key' })
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
} else {
|
||||||
|
const objectStore = open.transaction.objectStore(table)
|
||||||
|
if (!objectStore.indexNames.contains('timestamp')) {
|
||||||
|
objectStore.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open.onsuccess = function () {
|
||||||
|
var db = open.result
|
||||||
|
var tx = db.transaction(table, 'readwrite')
|
||||||
|
var store = tx.objectStore(table)
|
||||||
|
rows.forEach(row => {
|
||||||
|
store.put({ ...row, _date: new Date().toLocaleString(), timestamp: Date.now() })
|
||||||
|
});
|
||||||
|
tx.oncomplete = function () {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads data from an IndexedDB object store.
|
||||||
|
* It can read a single record by key, multiple records by an array of keys, or all records.
|
||||||
|
*
|
||||||
|
* @param {string|string[]|null} keys - The key(s) to read.
|
||||||
|
* - If `string`: Reads a single record and returns the data object directly.
|
||||||
|
* - If `string[]`: Reads multiple records and returns a Map of `rowkey` to `data` objects.
|
||||||
|
* - If `null` or `undefined` or `empty string/array`: Reads all records and returns a Map of `rowkey` to `data` objects.
|
||||||
|
* @param {string} table - The name of the IndexedDB object store (table).
|
||||||
|
* @param {string} database - The name of the IndexedDB database.
|
||||||
|
* @returns {Promise<any|Map<string, any>>} A promise that resolves with the data.
|
||||||
|
* - Single key: Resolves with the data object or `undefined` if not found.
|
||||||
|
* - Array of keys or All records: Resolves with a `Map` where keys are rowkeys and values are data objects.
|
||||||
|
* The Map will be empty if no records are found.
|
||||||
|
* - Rejects if there's an error opening the database or during the transaction.
|
||||||
|
*/
|
||||||
|
export const readIndexDB = (keys=null, table, database) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let openRequest = indexedDB.open(database)
|
||||||
|
openRequest.onupgradeneeded = function () {
|
||||||
|
// console.log('readIndexDB onupgradeneeded', table, )
|
||||||
|
var db = openRequest.result
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains(table)) {
|
||||||
|
var store = db.createObjectStore(table, { keyPath: 'key' })
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
} else {
|
||||||
|
const logStore = openRequest.transaction.objectStore(table)
|
||||||
|
if (!logStore.indexNames.contains('timestamp')) {
|
||||||
|
logStore.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openRequest.onerror = function (e) {
|
||||||
|
console.error(`Error opening database.`, table, e)
|
||||||
|
reject('Error opening database.')
|
||||||
|
}
|
||||||
|
openRequest.onsuccess = function (e) {
|
||||||
|
let db = e.target.result
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains(table)) {
|
||||||
|
resolve('Database does not exist.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let transaction = db.transaction(table, 'readonly')
|
||||||
|
let store = transaction.objectStore(table)
|
||||||
|
// read by key
|
||||||
|
// Handle array of keys
|
||||||
|
if (Array.isArray(keys) && keys.length > 0) {
|
||||||
|
const promises = keys.map(key => {
|
||||||
|
return new Promise((innerResolve) => {
|
||||||
|
const getRequest = store.get(key);
|
||||||
|
getRequest.onsuccess = (event) => {
|
||||||
|
const result = event.target.result;
|
||||||
|
if (result) {
|
||||||
|
// console.log(`💾Found record with key ${key}:`, result);
|
||||||
|
innerResolve([key, result]); // Resolve with [key, data] tuple
|
||||||
|
} else {
|
||||||
|
// console.log(`No record found with key ${key}.`);
|
||||||
|
innerResolve(void 0); // Resolve with undefined for non-existent keys
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getRequest.onerror = (event) => {
|
||||||
|
console.error(`Error getting record with key ${key}:`, event.target.error);
|
||||||
|
innerResolve(undefined); // Resolve with undefined on error, or innerReject if you want to fail fast
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises)
|
||||||
|
.then(results => {
|
||||||
|
const resultMap = new Map();
|
||||||
|
results.forEach(item => {
|
||||||
|
if (item !== undefined) {
|
||||||
|
resultMap.set(item[0], item[1]); // item[0] is key, item[1] is data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resolve(resultMap);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error during batch read:', error);
|
||||||
|
reject(error); // Reject the main promise if Promise.all encounters an error
|
||||||
|
});
|
||||||
|
} else if (!isEmpty(keys)) { // Handle single key
|
||||||
|
const getRequest = store.get(keys);
|
||||||
|
getRequest.onsuccess = (event) => {
|
||||||
|
const result = event.target.result;
|
||||||
|
if (result) {
|
||||||
|
// console.log(`💾Found record with key ${keys}:`, result);
|
||||||
|
resolve(result);
|
||||||
|
} else {
|
||||||
|
// console.log(`No record found with key ${keys}.`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getRequest.onerror = (event) => {
|
||||||
|
console.error(`Error getting record with key ${keys}:`, event.target.error);
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
} else { // Handle read all
|
||||||
|
const getAllRequest = store.getAll();
|
||||||
|
getAllRequest.onsuccess = (event) => {
|
||||||
|
const allData = event.target.result;
|
||||||
|
const resultMap = new Map();
|
||||||
|
if (allData && allData.length > 0) {
|
||||||
|
allData.forEach(item => {
|
||||||
|
resultMap.set(item.key, item);
|
||||||
|
});
|
||||||
|
// console.log(`💾Found all records:`, resultMap);
|
||||||
|
resolve(resultMap);
|
||||||
|
} else {
|
||||||
|
// console.log(`No records found.`);
|
||||||
|
resolve(resultMap); // Resolve with an empty Map if no records
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getAllRequest.onerror = (event) => {
|
||||||
|
console.error(`Error getting all records:`, event.target.error);
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
export const deleteIndexDBbyKey = (keys=null, table, database) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var open = indexedDB.open(database, INDEXED_DB_VERSION)
|
||||||
|
open.onupgradeneeded = function () {
|
||||||
|
// var db = open.result
|
||||||
|
// // 数据库是否存在
|
||||||
|
// if (!db.objectStoreNames.contains(table)) {
|
||||||
|
// var store = db.createObjectStore(table, { keyPath: 'id', autoIncrement: true })
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
open.onsuccess = function (e) {
|
||||||
|
let db = e.target.result
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains(table)) {
|
||||||
|
resolve('Database does not exist.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tx = db.transaction(table, 'readwrite')
|
||||||
|
var store = tx.objectStore(table)
|
||||||
|
if (Array.isArray(keys) && keys.length > 0) {
|
||||||
|
const promises = keys.map((key) => {
|
||||||
|
return new Promise((innerResolve) => {
|
||||||
|
const delRequest = store.delete(key)
|
||||||
|
delRequest.onsuccess = (event) => {
|
||||||
|
const result = event.target.result
|
||||||
|
if (result) {
|
||||||
|
innerResolve()
|
||||||
|
} else {
|
||||||
|
innerResolve(void 0) // Resolve with undefined for non-existent keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delRequest.onerror = (event) => {
|
||||||
|
innerResolve(undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Promise.allSettled(promises)
|
||||||
|
.then((results) => {
|
||||||
|
resolve(results)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
} else if (!isEmpty(keys)) { // Handle single key
|
||||||
|
const delRequest = store.delete(keys);
|
||||||
|
delRequest.onsuccess = (event) => {
|
||||||
|
const result = event.target.result;
|
||||||
|
if (result) {
|
||||||
|
resolve(result);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
delRequest.onerror = (event) => {
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 删除所有
|
||||||
|
let clearRequest = store.clear()
|
||||||
|
clearRequest.onsuccess = function (e) {
|
||||||
|
resolve(e.target.result)
|
||||||
|
}
|
||||||
|
clearRequest.onerror = function (e) {
|
||||||
|
reject(e.target.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.oncomplete = function () {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanOldData(database, storeNames=[], dateKey = 'timestamp') {
|
||||||
|
return function (daysToKeep = 7) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let deletedCount = 0
|
||||||
|
const recordsToDelete = new Set()
|
||||||
|
|
||||||
|
let openRequest = indexedDB.open(database, INDEXED_DB_VERSION)
|
||||||
|
openRequest.onupgradeneeded = function () {
|
||||||
|
var db = openRequest.result
|
||||||
|
storeNames.forEach(storeName => {
|
||||||
|
// 数据库是否存在
|
||||||
|
if (!db.objectStoreNames.contains(storeName)) {
|
||||||
|
var store = db.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true })
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
} else {
|
||||||
|
const logStore = openRequest.transaction.objectStore(storeName)
|
||||||
|
if (!logStore.indexNames.contains('timestamp')) {
|
||||||
|
logStore.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
openRequest.onsuccess = function (e) {
|
||||||
|
let db = e.target.result
|
||||||
|
// 数据库是否存在
|
||||||
|
// if (!db.objectStoreNames.contains(storeName)) {
|
||||||
|
// resolve('Database does not exist.')
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Calculate the cutoff timestamp for "X days ago"
|
||||||
|
const cutoffTimestamp = Date.now() - daysToKeep * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const objectStoreNames = isEmpty(storeNames) ? db.objectStoreNames : storeNames
|
||||||
|
|
||||||
|
if (!isEmpty(objectStoreNames)) {
|
||||||
|
const objectStores = Array.from(objectStoreNames).map((storeName) => db.transaction([storeName], 'readwrite').objectStore(storeName))
|
||||||
|
|
||||||
|
for (const objectStore of objectStores) {
|
||||||
|
// Identify old data using the date index and primary key ID
|
||||||
|
|
||||||
|
if (!objectStore.indexNames.contains(`${dateKey}`)) {
|
||||||
|
// Clear the store
|
||||||
|
let clearRequest = objectStore.clear()
|
||||||
|
console.log(`Cleanup complete. clear ${objectStore.name} records.`)
|
||||||
|
resolve()
|
||||||
|
clearRequest.onerror = function (e) {}
|
||||||
|
clearRequest.onsuccess = function (e) {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Get records older than 'daysToKeep' using the index
|
||||||
|
const dateIndex = objectStore.index(`${dateKey}`)
|
||||||
|
const dateRange = IDBKeyRange.upperBound(cutoffTimestamp, false) // Get keys < cutoffTimestamp (strictly older)
|
||||||
|
|
||||||
|
const dateCursorRequest = dateIndex.openCursor(dateRange)
|
||||||
|
|
||||||
|
dateCursorRequest.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result
|
||||||
|
if (cursor) {
|
||||||
|
recordsToDelete.add(cursor.primaryKey) // Add the primary key of the record to the set
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
const storeName = objectStore.name;
|
||||||
|
// Delete identified data in a new transaction
|
||||||
|
const deleteTransaction = db.transaction([storeName], 'readwrite')
|
||||||
|
const deleteObjectStore = deleteTransaction.objectStore(storeName)
|
||||||
|
|
||||||
|
deleteTransaction.oncomplete = () => {
|
||||||
|
console.log(`Cleanup complete. Deleted ${deletedCount} records in ${database}.${storeName}.`)
|
||||||
|
resolve(deletedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTransaction.onerror = (event) => {
|
||||||
|
console.error('Deletion transaction error:', event.target.error)
|
||||||
|
reject(event.target.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Set to Array for forEach
|
||||||
|
Array.from(recordsToDelete).forEach((key) => {
|
||||||
|
const deleteRequest = deleteObjectStore.delete(key)
|
||||||
|
deleteRequest.onsuccess = () => {
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
deleteRequest.onerror = (event) => {
|
||||||
|
console.warn(`Failed to delete record with key ${key}:`, event.target.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dateCursorRequest.onerror = (event) => {
|
||||||
|
console.error('Error opening date cursor for deletion:', event.target.error)
|
||||||
|
reject(event.target.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openRequest.onerror = function (e) {
|
||||||
|
reject('Error opening database:'+database, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clean7DaysWebsocketLog = cleanOldData('LogWebsocketData', ['LogStore']);
|
||||||
|
export const clean7DaysMailboxLog = cleanOldData('mailbox');
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存清除策略: 清理7天前的
|
||||||
|
* - 每次进入
|
||||||
|
* - 每天半夜
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LAST_SCHEDULED_CLEANUP_DAY_KEY = 'lastScheduledCleanupDay'; // For tracking scheduling
|
||||||
|
export const LAST_EXECUTED_CLEANUP_DAY_KEY = 'lastExecutedCleanupDay'; // For tracking actual execution
|
||||||
|
let cleanupTimeoutId = null; // To store the ID of the setTimeout
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the cleanup needs to be scheduled for today.
|
||||||
|
* This is based on when it was *last scheduled* to prevent re-scheduling
|
||||||
|
* if the app was merely refreshed within the same day.
|
||||||
|
* @returns {boolean} True if a new schedule for today is needed.
|
||||||
|
*/
|
||||||
|
function shouldScheduleForToday() {
|
||||||
|
const lastScheduledDay = localStorage.getItem(LAST_SCHEDULED_CLEANUP_DAY_KEY);
|
||||||
|
const today = new Date().toDateString(); // e.g., "Fri Jun 13 2025"
|
||||||
|
|
||||||
|
return !lastScheduledDay || lastScheduledDay !== today;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the cleanup was already *executed* today.
|
||||||
|
* This is to prevent running the cleanup task multiple times in one day
|
||||||
|
* if the app stays open past midnight or if it is refreshed.
|
||||||
|
* @returns {boolean} True if the cleanup has not executed today.
|
||||||
|
*/
|
||||||
|
function hasCleanupExecutedToday() {
|
||||||
|
const lastExecutedDay = localStorage.getItem(LAST_EXECUTED_CLEANUP_DAY_KEY);
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
return lastExecutedDay === today;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the cleanup and updates the last execution timestamp.
|
||||||
|
* This function is designed to be called via requestIdleCallback.
|
||||||
|
*/
|
||||||
|
export async function executeDailyCleanupTask() {
|
||||||
|
// const lastExecutedDay = localStorage.getItem(LAST_EXECUTED_CLEANUP_DAY_KEY)
|
||||||
|
const today = new Date().toDateString()
|
||||||
|
|
||||||
|
if (!hasCleanupExecutedToday()) {
|
||||||
|
if ('requestIdleCallback' in window) {
|
||||||
|
// console.log(`[${new Date().toLocaleTimeString()}] Scheduling cleanup via requestIdleCallback for execution.`);
|
||||||
|
|
||||||
|
requestIdleCallback(
|
||||||
|
async (deadline) => {
|
||||||
|
console.log(`[${new Date().toLocaleTimeString()}] Running scheduled cleanup. Time remaining: ${deadline.timeRemaining().toFixed(2)}ms, Did timeout: ${deadline.didTimeout}`)
|
||||||
|
try {
|
||||||
|
await clean7DaysMailboxLog()
|
||||||
|
await clean7DaysWebsocketLog()
|
||||||
|
// Mark that cleanup was successfully executed for today
|
||||||
|
localStorage.setItem(LAST_EXECUTED_CLEANUP_DAY_KEY, today)
|
||||||
|
console.log('Daily cleanup marked as executed for today.')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during scheduled cleanup execution:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
) // Give it up to 5 seconds to find idle time
|
||||||
|
} else {
|
||||||
|
console.warn('requestIdleCallback not supported. Executing cleanup directly (might cause jank).')
|
||||||
|
// Fallback for very old browsers: run directly.
|
||||||
|
try {
|
||||||
|
await clean7DaysMailboxLog()
|
||||||
|
await clean7DaysWebsocketLog()
|
||||||
|
localStorage.setItem(LAST_EXECUTED_CLEANUP_DAY_KEY, today)
|
||||||
|
console.log('Daily cleanup marked as executed for today (without rIC).')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during direct cleanup execution:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[${new Date().toLocaleTimeString()}] Cleanup already executed today.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates or re-initiates the daily midnight cleanup scheduler.
|
||||||
|
* This function calls itself recursively to set up the next day's schedule.
|
||||||
|
*/
|
||||||
|
export function setupDailyMidnightCleanupScheduler() {
|
||||||
|
if (cleanupTimeoutId) {
|
||||||
|
clearTimeout(cleanupTimeoutId)
|
||||||
|
cleanupTimeoutId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const midnight = new Date(now)
|
||||||
|
|
||||||
|
// Set to midnight (00:00:00)
|
||||||
|
midnight.setDate(now.getDate() + 1)
|
||||||
|
midnight.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const msToMidnight = midnight.getTime() - now.getTime()
|
||||||
|
|
||||||
|
console.log(`[${new Date().toLocaleTimeString()}] Scheduling next daily cleanup at ${midnight.toLocaleTimeString()}, in ${msToMidnight / (1000 * 60 * 60)} hours.`)
|
||||||
|
|
||||||
|
// Set the timeout for the next midnight
|
||||||
|
cleanupTimeoutId = setTimeout(async () => {
|
||||||
|
console.log(`[${new Date().toLocaleTimeString()}] Midnight trigger fired.`)
|
||||||
|
if (!hasCleanupExecutedToday()) {
|
||||||
|
await executeDailyCleanupTask()
|
||||||
|
} else {
|
||||||
|
console.log(`[${new Date().toLocaleTimeString()}] Cleanup already executed today, skipping re-execution.`)
|
||||||
|
}
|
||||||
|
setupDailyMidnightCleanupScheduler()
|
||||||
|
}, msToMidnight)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
|||||||
|
import { POPUP_FEATURES } from '@/config'
|
||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const EmailContent = ({ id, content: MailContent, className='', ...props }) => {
|
||||||
|
const [iframeHeight, setIframeHeight] = useState(5000) // Initial height
|
||||||
|
const [content, setContent] = useState(MailContent)
|
||||||
|
const iframeRef = useRef(null)
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setContent(MailContent)
|
||||||
|
}, [MailContent])
|
||||||
|
|
||||||
|
const setIframeContent = (iframe, content) => {
|
||||||
|
if (!iframe || !iframe.contentDocument) {
|
||||||
|
console.error('Iframe not loaded or contentDocument is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = iframe.contentDocument
|
||||||
|
doc.open()
|
||||||
|
// doc.write(content)
|
||||||
|
doc.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
/*overflow-y: hidden;*/
|
||||||
|
width: 900px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 90%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
img:not(a img){ cursor: pointer;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
doc.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateHeight = () => {
|
||||||
|
try {
|
||||||
|
if (iframeRef.current && iframeRef.current.contentDocument) {
|
||||||
|
const doc = iframeRef.current.contentDocument
|
||||||
|
const body = doc.body
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
try {
|
||||||
|
const links = doc.querySelectorAll('a')
|
||||||
|
links.forEach((link) => {
|
||||||
|
link.setAttribute('target', '_blank')
|
||||||
|
})
|
||||||
|
const imgs = doc.querySelectorAll('img:not(a img)')
|
||||||
|
imgs.forEach((img) => {
|
||||||
|
// open img in new tab
|
||||||
|
img.addEventListener('click', (e) => {
|
||||||
|
// e.preventDefault()
|
||||||
|
img.style.cursor = 'pointer'
|
||||||
|
window.open(img.src, img.src, POPUP_FEATURES)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('Could not access iframe content due to Same-Origin Policy or other error:', e)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const newHeight = Math.max(body.scrollHeight, body.offsetHeight, body.clientHeight)
|
||||||
|
|
||||||
|
// console.log('body.scrollHeight: ', body.scrollHeight)
|
||||||
|
// console.log('body.offsetHeight: ', body.offsetHeight)
|
||||||
|
// console.log('body.clientHeight: ', body.clientHeight)
|
||||||
|
|
||||||
|
const addMore = Math.max(Math.ceil(newHeight * 0.05), 120)
|
||||||
|
// console.log('Calculated height:', newHeight, addMore)
|
||||||
|
setIframeHeight(newHeight + addMore)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
console.warn('iframe body is null or undefined')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('iframeRef.current or contentDocument is null')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calculating height:', error)
|
||||||
|
}
|
||||||
|
// setIframeHeight(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLoad = () => {
|
||||||
|
calculateHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIframe = iframeRef.current
|
||||||
|
|
||||||
|
if (currentIframe) {
|
||||||
|
currentIframe.addEventListener('load', handleLoad)
|
||||||
|
setIframeContent(currentIframe, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (currentIframe) {
|
||||||
|
currentIframe.removeEventListener('load', handleLoad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if(iframeRef.current){
|
||||||
|
// setIframeContent(iframeRef.current, content);
|
||||||
|
// calculateHeight();
|
||||||
|
// }
|
||||||
|
// }, [content])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={`space-y-4 w-full ${className}`}>
|
||||||
|
<div className='w-full relative pt-2'>
|
||||||
|
<iframe
|
||||||
|
key={id}
|
||||||
|
ref={iframeRef}
|
||||||
|
height={iframeHeight}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: `${iframeHeight}px`,
|
||||||
|
// border: '1px solid #e5e7eb',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
sandbox='allow-scripts allow-same-origin allow-popups'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailContent
|
@ -0,0 +1,145 @@
|
|||||||
|
import { createContext, useEffect, useState, useRef, useMemo } from 'react'
|
||||||
|
import { App, Button, Card, Empty, Flex, Select, Spin, Typography, Divider, Modal, List, Row, Col, Tag, Drawer, Input, Tooltip } from 'antd'
|
||||||
|
import { CloseOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { InboxIcon, SendPlaneFillIcon, ExpandIcon } from '@/components/Icons'
|
||||||
|
import EmailDetailInline from './EmailDetailInline'
|
||||||
|
import { debounce, isEmpty } from '@/utils/commons'
|
||||||
|
import useConversationStore from '@/stores/ConversationStore';
|
||||||
|
|
||||||
|
const EmailListDrawer = ({ showExpandBtn=true, title, list: otherEmailList, currentConversationID, opi_sn, oid, emailItem: clickItem, onOpenEditor, ...props }) => {
|
||||||
|
|
||||||
|
const [, setEmailMsg] = useConversationStore((state) => [state.emailMsg, state.setEmailMsg]);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [selectedEmail, setSelectedEmail] = useState({})
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
|
const [dataSource, setDataSource] = useState([])
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setDataSource(otherEmailList)
|
||||||
|
// setSelectedEmail({ MAI_SN: -1000 });
|
||||||
|
|
||||||
|
return () => {}
|
||||||
|
}, [otherEmailList])
|
||||||
|
|
||||||
|
const onClearSearch = () => {
|
||||||
|
setDataSource(otherEmailList)
|
||||||
|
}
|
||||||
|
const handleSearch = (value) => {
|
||||||
|
if (isEmpty(value)) onClearSearch()
|
||||||
|
const res = otherEmailList.filter((ele) => `${ele.MAI_Subject}${ele.SenderReceiver}`.toLowerCase().includes(value.toLowerCase()))
|
||||||
|
setDataSource(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickEmailItem = (emailItem) => {
|
||||||
|
const emailMsg = {
|
||||||
|
conversationid: currentConversationID,
|
||||||
|
order_opi: opi_sn,
|
||||||
|
coli_sn: oid,
|
||||||
|
id: emailItem.MAI_SN,
|
||||||
|
MAI_SN: emailItem.MAI_SN,
|
||||||
|
msgOrigin: {
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
...(emailItem?.msgOrigin || {}),
|
||||||
|
id: emailItem.MAI_SN,
|
||||||
|
email: { mai_sn: emailItem.MAI_SN, subject: emailItem.MAI_Subject, id: emailItem.MAI_SN },
|
||||||
|
subject: emailItem.MAI_Subject,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
console.log('emailItem', emailItem);
|
||||||
|
setSelectedEmail(emailMsg)
|
||||||
|
setEmailMsg(emailMsg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [pageCurrent, setPageCurrent] = useState(1);
|
||||||
|
const onChangePagination = (page, size) => {
|
||||||
|
setPageCurrent(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEmpty(clickItem)) {
|
||||||
|
const itemIndex = dataSource.findIndex((ele) => ele.MAI_SN === clickItem.MAI_SN);
|
||||||
|
const page = Math.ceil((itemIndex+1) / 8) || 1;
|
||||||
|
setPageCurrent(page);
|
||||||
|
onClickEmailItem({...clickItem, ...dataSource[itemIndex]});
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {}
|
||||||
|
}, [clickItem]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showExpandBtn ? <Button
|
||||||
|
icon={<ExpandIcon />}
|
||||||
|
type={'primary'}
|
||||||
|
className='ml-2'
|
||||||
|
ghost
|
||||||
|
size='small'
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
/> : null}
|
||||||
|
<Drawer
|
||||||
|
zIndex={3}
|
||||||
|
mask={false}
|
||||||
|
width={1000}
|
||||||
|
styles={{ header: {} }}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<Button icon={<CloseOutlined />} onClick={() => setOpen(false)} type='text' size='small' className='text-gray-500' />
|
||||||
|
<b>{title || '邮件列表'}</b>
|
||||||
|
<Input.Search
|
||||||
|
className=''
|
||||||
|
ref={searchInputRef}
|
||||||
|
allowClear
|
||||||
|
onClear={onClearSearch}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
handleSearch(e.target.value)
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
onSearch={(v, e, { source }) => handleSearch(v)}
|
||||||
|
placeholder={`输入: 标题/发件人, 回车搜索`}
|
||||||
|
/>
|
||||||
|
<List
|
||||||
|
dataSource={dataSource}
|
||||||
|
className='h-[30vh] overflow-y-auto'
|
||||||
|
pagination={false}
|
||||||
|
renderItem={(emailItem) => (
|
||||||
|
<List.Item
|
||||||
|
className={`hover:bg-stone-50 cursor-pointer !py-1 ${selectedEmail.MAI_SN === emailItem.MAI_SN ? 'bg-blue-100 font-bold ' : ''}`}
|
||||||
|
onClick={() => onClickEmailItem(emailItem)}>
|
||||||
|
<Flex vertical={false} wrap={false} className='w-full'>
|
||||||
|
<div className='flex-auto ml-auto min-w-40 line-clamp-2'>
|
||||||
|
{emailItem.Direction === '收' ? <InboxIcon className='text-indigo-500' /> : <SendPlaneFillIcon className='text-primary' />}
|
||||||
|
{/* <Tooltip title={emailItem.MAI_Subject}> */}
|
||||||
|
<Typography.Text>{emailItem.MAI_Subject}</Typography.Text>
|
||||||
|
{/* </Tooltip> */}
|
||||||
|
</div>
|
||||||
|
<div className='ml-1 max-w-40'>
|
||||||
|
<Typography.Text ellipsis={{ tooltip: emailItem.SenderReceiver }}>{emailItem.SenderReceiver.replaceAll('"', '')}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div className='ml-1 max-w-20'>
|
||||||
|
<Typography.Text ellipsis={{ tooltip: emailItem.MAI_SendDate }}>{dayjs(emailItem.MAI_SendDate).format('MM-DD HH:mm')}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
classNames={{ header: '!py-1 !px-2 [&_.ant-drawer-title]:font-normal [&_.ant-list-pagination]:m-0', body: '!p-1 [&_.ant-list-pagination]:ms-1' }}
|
||||||
|
placement='right'
|
||||||
|
closeIcon={null}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
open={open}>
|
||||||
|
<EmailDetailInline {...{ mailID: selectedEmail.MAI_SN, emailMsg: selectedEmail, onOpenEditor }} />
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default EmailListDrawer
|
@ -0,0 +1,14 @@
|
|||||||
|
import { createContext, useEffect, useState } from 'react'
|
||||||
|
import { Drawer } from 'antd'
|
||||||
|
import SnippetList from '@/views/accounts/SnippetList'
|
||||||
|
import useSnippetStore from '@/stores/SnippetStore'
|
||||||
|
|
||||||
|
const GenerateAutoDocDrawer = ({ ...props }) => {
|
||||||
|
const [openSnippetDrawer, closeSnippetDrawer, snippetDrawerOpen] = useSnippetStore((state) => [state.openDrawer, state.closeDrawer, state.drawerOpen])
|
||||||
|
return (
|
||||||
|
<Drawer title='图文集' placement={'top'} size={'large'} onClose={() => closeSnippetDrawer()} open={snippetDrawerOpen}>
|
||||||
|
<SnippetList></SnippetList>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default GenerateAutoDocDrawer
|
@ -1,114 +0,0 @@
|
|||||||
import { createContext, useEffect, useState, useRef, useMemo } from 'react'
|
|
||||||
import { App, Button, Card, Empty, Flex, Select, Spin, Typography, Divider, Modal, List, Row, Col, Tag, Drawer, Input, Tooltip } from 'antd'
|
|
||||||
import { DownloadOutlined } from '@ant-design/icons';
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { InboxIcon, SendPlaneFillIcon, ExpandIcon } from '@/components/Icons'
|
|
||||||
import EmailDetailInline from '../Components/EmailDetailInline'
|
|
||||||
import { debounce, isEmpty } from '@/utils/commons'
|
|
||||||
|
|
||||||
const SupplierEmailDrawer = ({ list: otherEmailList, currentConversationID, opi_sn, oid, ...props }) => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [selectedEmail, setSelectedEmail] = useState({})
|
|
||||||
const searchInputRef = useRef(null)
|
|
||||||
const [dataSource, setDataSource] = useState([])
|
|
||||||
useEffect(() => {
|
|
||||||
setOpen(false);
|
|
||||||
setDataSource(otherEmailList)
|
|
||||||
// setSelectedEmail({ MAI_SN: -1000 });
|
|
||||||
|
|
||||||
return () => {}
|
|
||||||
}, [otherEmailList])
|
|
||||||
const onClearSearch = () => {
|
|
||||||
setDataSource(otherEmailList)
|
|
||||||
}
|
|
||||||
const handleSearch = (value) => {
|
|
||||||
if (isEmpty(value)) onClearSearch()
|
|
||||||
const res = otherEmailList.filter((ele) => `${ele.MAI_Subject}${ele.SenderReceiver}`.toLowerCase().includes(value.toLowerCase()))
|
|
||||||
setDataSource(res)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
icon={<ExpandIcon />}
|
|
||||||
type={'primary'}
|
|
||||||
className='ml-2'
|
|
||||||
ghost
|
|
||||||
size='small'
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Drawer
|
|
||||||
zIndex={3}
|
|
||||||
mask={false}
|
|
||||||
width={900}
|
|
||||||
styles={{ header: {} }}
|
|
||||||
title={`供应商邮件`}
|
|
||||||
classNames={{ header: '!py-1 !px-2', body: '!p-1 [&_.ant-list-pagination]:ms-1' }}
|
|
||||||
placement='right'
|
|
||||||
onClose={() => {
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
open={open}>
|
|
||||||
<Input.Search
|
|
||||||
className=''
|
|
||||||
ref={searchInputRef}
|
|
||||||
allowClear
|
|
||||||
onClear={onClearSearch}
|
|
||||||
onPressEnter={(e) => {
|
|
||||||
handleSearch(e.target.value)
|
|
||||||
return false
|
|
||||||
}}
|
|
||||||
onSearch={(v, e, { source }) => handleSearch(v)}
|
|
||||||
placeholder={`输入: 标题/发件人, 回车搜索`}
|
|
||||||
/>
|
|
||||||
<List
|
|
||||||
dataSource={dataSource}
|
|
||||||
pagination={{
|
|
||||||
pageSize: 10,
|
|
||||||
// showLessItems: true,
|
|
||||||
showSizeChanger: false,
|
|
||||||
size: 'small',
|
|
||||||
}}
|
|
||||||
renderItem={(emailItem) => (
|
|
||||||
<List.Item
|
|
||||||
className={`hover:bg-stone-50 cursor-pointer !py-1 ${selectedEmail.MAI_SN === emailItem.MAI_SN ? 'bg-blue-100 font-bold ' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
const emailMsg = {
|
|
||||||
conversationid: currentConversationID,
|
|
||||||
order_opi: opi_sn,
|
|
||||||
coli_sn: oid,
|
|
||||||
id: emailItem.MAI_SN,
|
|
||||||
MAI_SN: emailItem.MAI_SN,
|
|
||||||
msgOrigin: {
|
|
||||||
from: '',
|
|
||||||
to: '',
|
|
||||||
id: emailItem.MAI_SN,
|
|
||||||
email: { mai_sn: emailItem.MAI_SN, subject: emailItem.MAI_Subject, id: emailItem.MAI_SN },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
setSelectedEmail(emailMsg)
|
|
||||||
}}>
|
|
||||||
<Flex vertical={false} wrap={false} className='w-full'>
|
|
||||||
<div className='flex-auto ml-auto min-w-40 line-clamp-2'>
|
|
||||||
{emailItem.Direction === '收' ? <InboxIcon className='text-indigo-500' /> : <SendPlaneFillIcon className='text-primary' />}
|
|
||||||
{/* <Tooltip title={emailItem.MAI_Subject}> */}
|
|
||||||
<Typography.Text >{emailItem.MAI_Subject}</Typography.Text>
|
|
||||||
{/* </Tooltip> */}
|
|
||||||
</div>
|
|
||||||
<div className='ml-1 max-w-40'>
|
|
||||||
<Typography.Text ellipsis={{ tooltip: emailItem.SenderReceiver }}>{emailItem.SenderReceiver.replaceAll('"', '')}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div className='ml-1 max-w-20'>
|
|
||||||
<Typography.Text ellipsis={{ tooltip: emailItem.MAI_SendDate }}>{dayjs(emailItem.MAI_SendDate).format('MM-DD HH:mm')}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</Flex>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<EmailDetailInline {...{ mailID: selectedEmail.MAI_SN, emailMsg: selectedEmail }} />
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export default SupplierEmailDrawer
|
|
@ -0,0 +1,28 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { App, ConfigProvider } from 'antd'
|
||||||
|
import useStyleStore from '@/stores/StyleStore'
|
||||||
|
import EmailDetailInline from './Conversations/Online/Components/EmailDetailInline';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 独立窗口查看邮件
|
||||||
|
*
|
||||||
|
* - 从销售平台进入: 自动复制 storage, 可读取loginUser
|
||||||
|
*
|
||||||
|
* ! 无状态管理
|
||||||
|
*/
|
||||||
|
const EmailDetailWindow = () => {
|
||||||
|
const pageParam = useParams();
|
||||||
|
// console.log(pageParam)
|
||||||
|
|
||||||
|
// const [mobile] = useStyleStore((state) => [state.mobile])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
|
||||||
|
<EmailDetailInline mailID={pageParam.mailid} variant={'full'} />
|
||||||
|
</ConfigProvider>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default EmailDetailWindow
|
@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
|
import { Button, Modal, Form, Input, Radio } from 'antd'
|
||||||
|
import { searchEmailListAction } from '@/actions/EmailActions'
|
||||||
|
import useConversationStore from '@/stores/ConversationStore'
|
||||||
|
|
||||||
|
const MailListSearchModal = ({ ...props }) => {
|
||||||
|
const [currentMailboxOPI] = useConversationStore((state) => [state.currentMailboxOPI])
|
||||||
|
|
||||||
|
const [openForm, setOpenForm] = useState(false)
|
||||||
|
const [formSearch] = Form.useForm()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const onSubmitSearchMailList = async (values) => {
|
||||||
|
setLoading(true)
|
||||||
|
await searchEmailListAction({...values, opi_sn: currentMailboxOPI});
|
||||||
|
setLoading(false)
|
||||||
|
setOpenForm(false)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button key={'bound'} onClick={() => setOpenForm(true)} size='small' icon={<SearchOutlined className='' />}>
|
||||||
|
查找邮件
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
width={window.innerWidth < 700 ? '95%' : 960}
|
||||||
|
open={openForm}
|
||||||
|
cancelText='关闭'
|
||||||
|
okText='查找'
|
||||||
|
confirmLoading={loading}
|
||||||
|
okButtonProps={{ autoFocus: true, htmlType: 'submit', type: 'default' }}
|
||||||
|
onCancel={() => setOpenForm(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
modalRender={(dom) => (
|
||||||
|
<Form
|
||||||
|
layout='vertical'
|
||||||
|
form={formSearch}
|
||||||
|
name='searchmaillist_form_in_modal'
|
||||||
|
initialValues={{ mailboxtype: '' }}
|
||||||
|
clearOnDestroy
|
||||||
|
onFinish={(values) => onSubmitSearchMailList(values)}
|
||||||
|
className='[&_.ant-form-item]:m-2'>
|
||||||
|
{dom}
|
||||||
|
</Form>
|
||||||
|
)}>
|
||||||
|
<Form.Item name='mailboxtype' label='邮箱文件夹'>
|
||||||
|
<Radio.Group
|
||||||
|
options={[
|
||||||
|
{ key: 'All', value: '', label: 'All' },
|
||||||
|
{ key: '1', value: '1', label: '收件箱' },
|
||||||
|
{ key: '2', value: '2', label: '未读邮件' },
|
||||||
|
{ key: '3', value: '3', label: '已发邮件' },
|
||||||
|
{ key: '7', value: '7', label: '已处理邮件' },
|
||||||
|
]}
|
||||||
|
optionType='button'
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='sender' label='发件人'>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='receiver' label='收件人'>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='subject' label='主题'>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type='primary' htmlType='submit' loading={loading}>
|
||||||
|
查找
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default MailListSearchModal
|
@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
|
import { Button, Modal, Form, Input, Checkbox, Radio, DatePicker, Divider, Typography, Flex } from 'antd'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { getEmailDirAction, queryHTOrderListAction, } from '@/actions/EmailActions'
|
||||||
|
import { isEmpty, objectMapper, pick } from '@/utils/commons'
|
||||||
|
import useConversationStore from '@/stores/ConversationStore'
|
||||||
|
|
||||||
|
const MailOrderSearchModal = ({ ...props }) => {
|
||||||
|
const [currentMailboxOPI] = useConversationStore((state) => [state.currentMailboxOPI])
|
||||||
|
const [updateCurrentMailboxNestedDirs, setMailboxActiveNode] = useConversationStore((state) => [state.updateCurrentMailboxNestedDirs, state.setMailboxActiveNode])
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const onSubmitSearchMailOrder = async (values) => {
|
||||||
|
// console.log('Received values of form: ', values)
|
||||||
|
setLoading(true)
|
||||||
|
const valuesToSub = objectMapper(values, {
|
||||||
|
year: { key: 'year', transform: (val) => (val ? dayjs(val).year() : '') },
|
||||||
|
important: { key: 'important', transform: (val) => val || '-1' },
|
||||||
|
by_success: { key: 'by_success', transform: (val) => (val ? '1' : '0') },
|
||||||
|
if_want_book: { key: 'if_want_book', transform: (val) => (val ? '1' : '0') },
|
||||||
|
if_thinking: { key: 'if_thinking', transform: (val) => (val ? '1' : '0') },
|
||||||
|
by_start_date: { key: 'by_start_date', transform: (val) => (val ? '1' : '0') },
|
||||||
|
coli_id: { key: 'coli_id', transform: (val) => (val ? val : '') },
|
||||||
|
is_biz: { key: 'sourcetype', transform: (val) => (val ? '227002' : '227001') },
|
||||||
|
})
|
||||||
|
let result
|
||||||
|
if (isEmpty(valuesToSub.coli_id)) {
|
||||||
|
const { coli_id, sourcetype, ...mailboxParams } = valuesToSub
|
||||||
|
result = await getEmailDirAction({ ...mailboxParams, opi_sn: currentMailboxOPI }, false)
|
||||||
|
updateCurrentMailboxNestedDirs(result[`${currentMailboxOPI}`])
|
||||||
|
} else {
|
||||||
|
const htOrderParams = pick(valuesToSub, ['coli_id', 'sourcetype'])
|
||||||
|
result = await queryHTOrderListAction({ ...htOrderParams, opi_sn: currentMailboxOPI })
|
||||||
|
const addToTree = {
|
||||||
|
key: 'search-orders',
|
||||||
|
title: '查找订单',
|
||||||
|
iconIndex: 'search',
|
||||||
|
_raw: { COLI_SN: 0, IsTrue: 0 },
|
||||||
|
children: result.map((o) => ({
|
||||||
|
key: `search-${o.COLI_SN}`,
|
||||||
|
title: `${o.COLI_ID}`,
|
||||||
|
iconIndex: 13,
|
||||||
|
parent: 'search-orders',
|
||||||
|
parentTitle: '查找订单',
|
||||||
|
parentIconIndex: 'search',
|
||||||
|
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: 'search-orders', IsTrue: 0, ApplyDate: '', OrderSourceType: htOrderParams.sourcetype, parent: 'search-orders' },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
updateCurrentMailboxNestedDirs([addToTree])
|
||||||
|
setMailboxActiveNode(addToTree)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button key={'bound'} onClick={() => setOpen(true)} size='small' icon={<SearchOutlined className='' />}>
|
||||||
|
查找订单
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
width={window.innerWidth < 700 ? '95%' : 960}
|
||||||
|
// title='查找' //mask={false}
|
||||||
|
open={open}
|
||||||
|
cancelText='关闭'
|
||||||
|
okText='查找'
|
||||||
|
confirmLoading={loading}
|
||||||
|
okButtonProps={{ autoFocus: true, htmlType: 'submit', type: 'default' }}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
modalRender={(dom) => (
|
||||||
|
<Form
|
||||||
|
layout='inline'
|
||||||
|
// size='small'
|
||||||
|
form={form}
|
||||||
|
name='searchmailorder_form_in_modal'
|
||||||
|
initialValues={{ year: dayjs(), important: '-1' }}
|
||||||
|
clearOnDestroy
|
||||||
|
onFinish={(values) => onSubmitSearchMailOrder(values)}
|
||||||
|
className='[&_.ant-form-item]:m-2'>
|
||||||
|
{dom}
|
||||||
|
</Form>
|
||||||
|
)}>
|
||||||
|
<Flex wrap gap={8}>
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>按订单的时间范围</Typography.Text>
|
||||||
|
<Form.Item name='year' label='年份'>
|
||||||
|
<DatePicker picker='year' />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='important' label='重要程度'>
|
||||||
|
<Radio.Group
|
||||||
|
options={[
|
||||||
|
{ key: '-1', value: '-1', label: 'All' },
|
||||||
|
{ key: '240001', value: '240001', label: '普通' },
|
||||||
|
{ key: '240002', value: '240002', label: '较重要' },
|
||||||
|
{ key: '240003', value: '240003', label: '很重要' },
|
||||||
|
]}
|
||||||
|
optionType='button'
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div className='flex'>
|
||||||
|
<Form.Item name='by_success' className='' valuePropName='checked'>
|
||||||
|
<Checkbox>成行订单</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='if_want_book' className='' valuePropName='checked'>
|
||||||
|
<Checkbox>要预定</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='if_thinking' className='' valuePropName='checked'>
|
||||||
|
<Checkbox>犹豫中</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item name='by_start_date' className='' valuePropName='checked'>
|
||||||
|
<Checkbox>按出发日期</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<div className='text-end'>
|
||||||
|
<Button type='primary' htmlType='submit' loading={loading}>
|
||||||
|
查找
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className='my-2' />
|
||||||
|
<Typography.Text strong>订单号精确查找</Typography.Text>
|
||||||
|
<Form.Item name='coli_id' label='订单号' className=''>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='is_biz' className='' valuePropName='checked'>
|
||||||
|
<Checkbox>商务订单</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<div className='text-end'>
|
||||||
|
<Button type='primary' htmlType='submit' loading={loading}>
|
||||||
|
查找
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default MailOrderSearchModal
|
@ -0,0 +1,31 @@
|
|||||||
|
import { StarTwoTone, CalendarTwoTone, FolderOutlined, DeleteOutlined, ClockCircleOutlined, FormOutlined, DatabaseOutlined, BellTwoTone, SearchOutlined } from '@ant-design/icons'
|
||||||
|
import { InboxIcon, MailUnreadIcon, SendPlaneFillIcon } from '@/components/Icons'
|
||||||
|
|
||||||
|
const EmailDirTypeIcons = {
|
||||||
|
'search': { component: SearchOutlined, color: '', className: 'text-blue-600' },
|
||||||
|
'star': { component: StarTwoTone, color: '', className: '' },
|
||||||
|
'calendar': { component: CalendarTwoTone, color: '', className: '' },
|
||||||
|
'reminder': { component: BellTwoTone, color: '', className: '' },
|
||||||
|
0: { component: FolderOutlined, color: '#ffe78f', className: 'text-blue-500' },
|
||||||
|
1: { component: FolderOutlined, color: '#ffe78f', className: 'text-blue-500' },
|
||||||
|
3: { component: InboxIcon, color: '', className: 'text-indigo-500' },
|
||||||
|
17: { component: InboxIcon, color: '', className: 'text-indigo-500' },
|
||||||
|
11: { component: MailUnreadIcon, color: '', className: 'text-indigo-500' },
|
||||||
|
4: { component: SendPlaneFillIcon, color: '', className: 'text-primary' },
|
||||||
|
2: { component: ClockCircleOutlined, color: '', className: 'text-yellow-500' },
|
||||||
|
5: { component: FormOutlined, color: '', className: 'text-blue-500' },
|
||||||
|
7: { component: DeleteOutlined, color: '', className: 'text-red-500' },
|
||||||
|
// '3': { component: MailCheckIcon, color: '', className: 'text-yellow-600' },
|
||||||
|
12: { component: DatabaseOutlined, color: '', className: 'text-blue-600' },
|
||||||
|
13: { component: () => null, color: '', className: '' },
|
||||||
|
14: { component: () => '❗', color: '', className: '' }, // 240002 较重要/高品牌价值客户
|
||||||
|
15: { component: () => '❣️', color: '', className: '' }, // 240003 很重要/高订单价值客户
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MailboxDirIcon = ({ type }) => {
|
||||||
|
const Icon = EmailDirTypeIcons[type || '13']?.component || EmailDirTypeIcons['13'].component
|
||||||
|
const className = EmailDirTypeIcons[type || '13']?.className || EmailDirTypeIcons['13'].className
|
||||||
|
return <Icon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MailboxDirIcon
|
@ -0,0 +1,43 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { App, Dropdown } from 'antd'
|
||||||
|
import useConversationStore from '@/stores/ConversationStore'
|
||||||
|
import { emailTemplates, openPopup } from '@/hooks/useEmail'
|
||||||
|
import { isEmpty } from '@/utils/commons'
|
||||||
|
|
||||||
|
const NewEmailButton = ({ ...props }) => {
|
||||||
|
const { notification } = App.useApp()
|
||||||
|
const [mailboxActiveNode] = useConversationStore((state) => [state.mailboxActiveNode])
|
||||||
|
const [mailboxActiveCOLI] = useConversationStore((state) => [state.mailboxActiveCOLI])
|
||||||
|
|
||||||
|
const COLI_SN = useMemo(() => mailboxActiveCOLI || mailboxActiveNode?.COLI_SN || 0, [mailboxActiveNode.COLI_SN, mailboxActiveCOLI])
|
||||||
|
const handleTemplateDropdown = ({ key, domEvent }) => {
|
||||||
|
if (isEmpty(COLI_SN)) {
|
||||||
|
notification.warning({ message: '无法绑定订单', description: '请先选择到订单目录或订单邮件', placement: 'top' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
openPopup(`/email/new/0/${COLI_SN}/${key}`, `new-0-${COLI_SN}-${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewEmail = () => {
|
||||||
|
openPopup(`/email/new/0/${COLI_SN}`, `new-0-${COLI_SN}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown.Button
|
||||||
|
size='small'
|
||||||
|
className={`w-auto ${props.className}`}
|
||||||
|
placement='bottom'
|
||||||
|
arrow
|
||||||
|
type={'primary'}
|
||||||
|
menu={{
|
||||||
|
items: emailTemplates,
|
||||||
|
onClick: handleTemplateDropdown,
|
||||||
|
}}
|
||||||
|
onClick={handleNewEmail}>
|
||||||
|
新邮件
|
||||||
|
</Dropdown.Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default NewEmailButton
|