Compare commits
1190 Commits
dev/messag
...
vben-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db736a6f4 | ||
|
|
b82754e36b | ||
|
|
f0a33a628b | ||
|
|
3547c53f89 | ||
|
|
82b10bd122 | ||
|
|
b1f223554b | ||
|
|
871982fc07 | ||
|
|
689a0f64a6 | ||
|
|
335b3535d5 | ||
|
|
c4e833a1c4 | ||
|
|
7697af2619 | ||
|
|
396a6e5b8b | ||
|
|
7a504433da | ||
|
|
33dcd3f5d8 | ||
|
|
efd7e8ffc8 | ||
|
|
5b9f3ec3ed | ||
|
|
e434aa9534 | ||
|
|
32eb96b4ad | ||
|
|
9f37802ba7 | ||
|
|
e8d35a7471 | ||
|
|
f038166681 | ||
|
|
88cc837f6a | ||
|
|
82d790a6ab | ||
|
|
9c3ba53990 | ||
|
|
cb707c8c78 | ||
|
|
cfc8390573 | ||
|
|
174693f6db | ||
|
|
98761dce98 | ||
|
|
c1bc234509 | ||
|
|
dc9f9c5c13 | ||
|
|
d3dc081b5d | ||
|
|
0331325929 | ||
|
|
9c629ff8ee | ||
|
|
bd5b0b2de1 | ||
|
|
2c1e643ab9 | ||
|
|
029b45d0bb | ||
|
|
0a47dc8a3d | ||
|
|
d0c8c887fd | ||
|
|
f16c780c2b | ||
|
|
ebe0eadee4 | ||
|
|
3cbb1e8411 | ||
|
|
21d9132703 | ||
|
|
212b16a8ec | ||
|
|
feebf4b14e | ||
|
|
3bc22d02e6 | ||
|
|
9f5b1cd9fb | ||
|
|
32495e40b2 | ||
|
|
cb52807b27 | ||
|
|
371594bfda | ||
|
|
418c16e093 | ||
|
|
52a1cb1ce5 | ||
|
|
b04d728249 | ||
|
|
5506c8917f | ||
|
|
05885ed405 | ||
|
|
c8045e1da8 | ||
|
|
44483db9af | ||
|
|
fbf0cebf4b | ||
|
|
98344dfcec | ||
|
|
6d5ccc7ac8 | ||
|
|
b9c6d56f74 | ||
|
|
aaf0da582c | ||
|
|
9a5d052fa5 | ||
|
|
baea741432 | ||
|
|
33f4522bff | ||
|
|
cb6b0a8872 | ||
|
|
57e2a54219 | ||
|
|
4db254cd21 | ||
|
|
255a76d79a | ||
|
|
dcdbfe5dfc | ||
|
|
14414bf9b0 | ||
|
|
0542b509ca | ||
|
|
eaf585d03a | ||
|
|
0a45cb41fb | ||
|
|
517a42ec05 | ||
|
|
bacfdf5e42 | ||
|
|
2e69c3fefc | ||
|
|
43b78da024 | ||
|
|
decbed20bc | ||
|
|
1ac5ac3e82 | ||
|
|
b1df6fb4e8 | ||
|
|
5fcb65aa0d | ||
|
|
8d5a419c50 | ||
|
|
2d82505330 | ||
|
|
98dea92225 | ||
|
|
e6a23479ee | ||
|
|
bbb133476a | ||
|
|
294e9f2966 | ||
|
|
f43de252b5 | ||
|
|
700db76f16 | ||
|
|
ebc0f23e64 | ||
|
|
28757fb9c8 | ||
|
|
81f69d797d | ||
|
|
6b6708bcf2 | ||
|
|
5c1d31f680 | ||
|
|
899910547f | ||
|
|
70a1d0a2e0 | ||
|
|
24eb67224d | ||
|
|
e4e25028b5 | ||
|
|
ef27b4e349 | ||
|
|
b21563a89b | ||
|
|
d06451cdb7 | ||
|
|
ee4490a91c | ||
|
|
5318774efe | ||
|
|
68a2717a26 | ||
|
|
0a2c01e453 | ||
|
|
b330f08ef4 | ||
|
|
6676d962e9 | ||
|
|
dd8307dfe5 | ||
|
|
d0c327e037 | ||
|
|
901a147abb | ||
|
|
86e68bdde3 | ||
|
|
054deae3bb | ||
|
|
772286fb73 | ||
|
|
5936ff2e0f | ||
|
|
f840d235ef | ||
|
|
7656c3ac49 | ||
|
|
930597c605 | ||
|
|
41c20a43af | ||
|
|
fe65d548be | ||
|
|
0cd87c170f | ||
|
|
b71ebbd673 | ||
|
|
31d3bd18f3 | ||
|
|
c1b71142df | ||
|
|
92149f43a6 | ||
|
|
47a829c1b1 | ||
|
|
eae6771554 | ||
|
|
8ac66a96bc | ||
|
|
822cec0f5c | ||
|
|
6910c691f7 | ||
|
|
5ff6f0d837 | ||
|
|
8eca0bd84d | ||
|
|
71c30d3e18 | ||
|
|
263d8e5b52 | ||
|
|
b79b82ecf0 | ||
|
|
91a2e80be8 | ||
|
|
819ddd4ebb | ||
|
|
bb6403d513 | ||
|
|
45c3776ffd | ||
|
|
1d23cd707c | ||
|
|
6eb79b8ef0 | ||
|
|
8b7c245bc7 | ||
|
|
e416de3290 | ||
|
|
398b445d35 | ||
|
|
14d03a1c2d | ||
|
|
9769c11de5 | ||
|
|
cae47e8c7f | ||
|
|
88918aad10 | ||
|
|
75c3a7214b | ||
|
|
71bcabd118 | ||
|
|
81619b6090 | ||
|
|
77468ce9a6 | ||
|
|
8779c00670 | ||
|
|
61f1b53325 | ||
|
|
c56bb7d320 | ||
|
|
1490a93f47 | ||
|
|
e50296e3c5 | ||
|
|
5da38ccded | ||
|
|
20b69fa480 | ||
|
|
fdf2abb702 | ||
|
|
9d36b34c4c | ||
|
|
9b356d731d | ||
|
|
18e2bbf969 | ||
|
|
14bb94245c | ||
|
|
feb0265744 | ||
|
|
7182405d24 | ||
|
|
d7d6d2e337 | ||
|
|
31c31b914b | ||
|
|
f00a881295 | ||
|
|
5ed867eb95 | ||
|
|
ae5bd3afc5 | ||
|
|
a8c3729850 | ||
|
|
794edd419e | ||
|
|
9a284ee385 | ||
|
|
e031457271 | ||
|
|
0931599f96 | ||
|
|
dc3865f778 | ||
|
|
45399d0299 | ||
|
|
dc3466d77a | ||
|
|
65885ea74b | ||
|
|
769791351f | ||
|
|
741cbb3794 | ||
|
|
edd110ac4e | ||
|
|
7824219557 | ||
|
|
dd4afec7ed | ||
|
|
ebebb42625 | ||
|
|
d1e6d4a3da | ||
|
|
ad3f996bdb | ||
|
|
68877a7e97 | ||
|
|
c5a839f652 | ||
|
|
bf8c3d6411 | ||
|
|
f753a3b75f | ||
|
|
fcf726f953 | ||
|
|
16ebc67796 | ||
|
|
6b109c9ae6 | ||
|
|
160114c5e0 | ||
|
|
4b1fada0ab | ||
|
|
5c9daab2ff | ||
|
|
c2a85f4ec2 | ||
|
|
e7f541f37e | ||
|
|
f23c9f5e19 | ||
|
|
4459389d4a | ||
|
|
8e0564cc42 | ||
|
|
017b3c3fce | ||
|
|
126275b417 | ||
|
|
bc908e29eb | ||
|
|
efe3c25310 | ||
|
|
d85ac2c976 | ||
|
|
c816591b69 | ||
|
|
564b349700 | ||
|
|
ce495d67a0 | ||
|
|
8094d1ffb9 | ||
|
|
748b182e44 | ||
|
|
6544340d80 | ||
|
|
6b7cb7b1fc | ||
|
|
d90d45a128 | ||
|
|
66077b3bcf | ||
|
|
0328594747 | ||
|
|
790e114d41 | ||
|
|
cc60ca9592 | ||
|
|
79707a2ebd | ||
|
|
0eb72ca4fd | ||
|
|
7fd4b1de04 | ||
|
|
9ffd42f013 | ||
|
|
40101911f0 | ||
|
|
04fbb7a556 | ||
|
|
e5f9106caa | ||
|
|
108d7ff335 | ||
|
|
1d6842fa69 | ||
|
|
d6a9eaf9e9 | ||
|
|
f7540c119b | ||
|
|
acf289381e | ||
|
|
f813245827 | ||
|
|
e98f0b7558 | ||
|
|
cd86de54e1 | ||
|
|
60eb1335f8 | ||
|
|
f2b3b12553 | ||
|
|
c066889c37 | ||
|
|
6b81d1b0d5 | ||
|
|
126b33687a | ||
|
|
5b67c2e740 | ||
|
|
2d7c3c3072 | ||
|
|
5cc68c9dd0 | ||
|
|
63a38dce49 | ||
|
|
629f747795 | ||
|
|
8585008f79 | ||
|
|
00990d9453 | ||
|
|
2a84b590b5 | ||
|
|
769c970e08 | ||
|
|
4d3c481a93 | ||
|
|
fda4b39c01 | ||
|
|
74d8942f39 | ||
|
|
9ec98f0846 | ||
|
|
f71094e878 | ||
|
|
9badda3d11 | ||
|
|
03fcecadcc | ||
|
|
3a88adb8a6 | ||
|
|
c759cb2593 | ||
|
|
f77166b3a2 | ||
|
|
f7928b34b2 | ||
|
|
454a5b54bd | ||
|
|
a4dd9d30ce | ||
|
|
76cfcda2e9 | ||
|
|
7ec4df4995 | ||
|
|
d71c81e8ff | ||
|
|
aac626da32 | ||
|
|
5db93a345a | ||
|
|
7c943cc06b | ||
|
|
5c8dd1a6da | ||
|
|
6592135cc5 | ||
|
|
2f16ee7cf5 | ||
|
|
0c1b737325 | ||
|
|
5bbdcffb97 | ||
|
|
1867015e97 | ||
|
|
b6aeadd9a2 | ||
|
|
1e44b87359 | ||
|
|
a1081bf7a6 | ||
|
|
9bb2026b4d | ||
|
|
4554cd016a | ||
|
|
350d5ee60e | ||
|
|
1ec058cbe3 | ||
|
|
eb9cdbab9b | ||
|
|
4907f281af | ||
|
|
aadf9b6e39 | ||
|
|
84e77f64ea | ||
|
|
3e89077d46 | ||
|
|
c8c425538f | ||
|
|
b04c10aab6 | ||
|
|
b5f79db321 | ||
|
|
42d82875ce | ||
|
|
4d8d2de6ad | ||
|
|
294700a247 | ||
|
|
f55b18ffd7 | ||
|
|
ca5931e8c4 | ||
|
|
aac4e88353 | ||
|
|
9b54e50510 | ||
|
|
30356a24e6 | ||
|
|
ba60bc3c14 | ||
|
|
f5feddc6c7 | ||
|
|
0868deb82b | ||
|
|
1299acd8f9 | ||
|
|
43717807a4 | ||
|
|
1b4e126128 | ||
|
|
d23b246aee | ||
|
|
71f2e5f504 | ||
|
|
4bbd34fab9 | ||
|
|
08e4bb40b4 | ||
|
|
9c49f4bb1e | ||
|
|
28905b0bec | ||
|
|
bbcad709ca | ||
|
|
a5f0537cb0 | ||
|
|
c78d89f549 | ||
|
|
f2a17cbe78 | ||
|
|
9667232684 | ||
|
|
1f584ff0c9 | ||
|
|
75ed17a1f9 | ||
|
|
cc6ccaab38 | ||
|
|
0be4b51eaa | ||
|
|
b40822e3ed | ||
|
|
df5cb426d1 | ||
|
|
2d36d6b510 | ||
|
|
88d5661e0c | ||
|
|
8ce773f264 | ||
|
|
329fa68207 | ||
|
|
e1f6449073 | ||
|
|
51e8b27d4c | ||
|
|
e555ee065e | ||
|
|
b5dacd992f | ||
|
|
d55f17670a | ||
|
|
9f20d52b4e | ||
|
|
86445a38e4 | ||
|
|
9a73e961fc | ||
|
|
c0b2ef980e | ||
|
|
e8dc464e79 | ||
|
|
99c38c93c8 | ||
|
|
244c0a5884 | ||
|
|
c6fd599784 | ||
|
|
4ca2f1c6e8 | ||
|
|
36d7dc23fa | ||
|
|
42317ddf41 | ||
|
|
c7fd6ffb0e | ||
|
|
3a83fb0c3e | ||
|
|
5907c04e00 | ||
|
|
6885927441 | ||
|
|
c8c589ae7e | ||
|
|
d0889b5cc4 | ||
|
|
2f1a866cb4 | ||
|
|
fee32c1d12 | ||
|
|
f2652833a1 | ||
|
|
c3aa63982f | ||
|
|
4a968d9379 | ||
|
|
ec9b323195 | ||
|
|
d69455e8ef | ||
|
|
33e2582f60 | ||
|
|
991408b451 | ||
|
|
a096073a8e | ||
|
|
f30157fa59 | ||
|
|
12a81a7a7d | ||
|
|
e808fe74c1 | ||
|
|
4665a787a0 | ||
|
|
b7774fc9d9 | ||
|
|
6f18718c87 | ||
|
|
2a32715c99 | ||
|
|
46f323431c | ||
|
|
2b1fcb038b | ||
|
|
d779a39862 | ||
|
|
0c8db0f6be | ||
|
|
37393e1820 | ||
|
|
93e23c6b05 | ||
|
|
e023a0852a | ||
|
|
306739cabb | ||
|
|
093479bb33 | ||
|
|
bd369df3bc | ||
|
|
4f7b396ebb | ||
|
|
d700b04841 | ||
|
|
243456d928 | ||
|
|
7e8b9c2368 | ||
|
|
92298bac2b | ||
|
|
436a865bef | ||
|
|
185011ee21 | ||
|
|
feca01484b | ||
|
|
35e1166b9f | ||
|
|
d0c17090ef | ||
|
|
cc32ebeb12 | ||
|
|
02372f12ba | ||
|
|
e9bdbb610a | ||
|
|
2013ba4de4 | ||
|
|
e417a2c209 | ||
|
|
f1273571c7 | ||
|
|
064a6a20f0 | ||
|
|
cb5e7e00f1 | ||
|
|
5ed39c03a4 | ||
|
|
794b103aa9 | ||
|
|
c7b1bb3492 | ||
|
|
89349f8d91 | ||
|
|
8e0b65a0e1 | ||
|
|
b096e83f93 | ||
|
|
4a69c9e665 | ||
|
|
7a60a081a2 | ||
|
|
c693c854a7 | ||
|
|
5eca357a5c | ||
|
|
2930dcd78e | ||
|
|
e421d9d99b | ||
|
|
6c029bfa32 | ||
|
|
fe77bc8bc9 | ||
|
|
ccabbf0e97 | ||
|
|
5b84ac5b13 | ||
|
|
6be3a0e204 | ||
|
|
a9b76ba2ed | ||
|
|
53ccec1d80 | ||
|
|
4af5d6152b | ||
|
|
307781f437 | ||
|
|
1326994d8e | ||
|
|
fd70a3f3e0 | ||
|
|
298930b0d7 | ||
|
|
54d95b8761 | ||
|
|
4a16040d3e | ||
|
|
ee95548340 | ||
|
|
320e687bad | ||
|
|
ad43c6817e | ||
|
|
c8747c079d | ||
|
|
224bfe7fcb | ||
|
|
f443bfbc7b | ||
|
|
195b2ea0d2 | ||
|
|
4150479549 | ||
|
|
5ebf513498 | ||
|
|
4e4ffc439c | ||
|
|
ad7ed50b52 | ||
|
|
92f8916225 | ||
|
|
7e4edd270d | ||
|
|
332ff44219 | ||
|
|
834ce3efc0 | ||
|
|
5211f5065d | ||
|
|
96d6f89732 | ||
|
|
6ab06584eb | ||
|
|
a6433c2b50 | ||
|
|
128a131797 | ||
|
|
c775d7ed80 | ||
|
|
b8b4308e1c | ||
|
|
80d6e2255f | ||
|
|
4e0968d4b7 | ||
|
|
44a5809a46 | ||
|
|
2428fb1407 | ||
|
|
bb78882f72 | ||
|
|
df88a23102 | ||
|
|
ca5f360231 | ||
|
|
147b50ec45 | ||
|
|
34439dce4e | ||
|
|
9a22027b35 | ||
|
|
282a102826 | ||
|
|
417e6c2ade | ||
|
|
9d69d7f46c | ||
|
|
87d1593a1f | ||
|
|
7fbdf3d914 | ||
|
|
65287cf4b7 | ||
|
|
6da3017dcf | ||
|
|
5c02057198 | ||
|
|
a7ca7cdb9f | ||
|
|
79408d406d | ||
|
|
e555f71bf8 | ||
|
|
4c320346c3 | ||
|
|
e5ec88169a | ||
|
|
914711ae04 | ||
|
|
4c1e3b9548 | ||
|
|
9cd3987475 | ||
|
|
47a853330d | ||
|
|
2aced2f659 | ||
|
|
cd955df02f | ||
|
|
0a819df2bf | ||
|
|
67afcadcf0 | ||
|
|
1128ef5acd | ||
|
|
6b3506f128 | ||
|
|
5613dcef99 | ||
|
|
3528517fe0 | ||
|
|
2a5b520ec9 | ||
|
|
e2fb3602f1 | ||
|
|
da3580cbd7 | ||
|
|
0c300d040c | ||
|
|
d43a3729c3 | ||
|
|
bed97a84d8 | ||
|
|
b908076846 | ||
|
|
885a0a9a00 | ||
|
|
340baf4f0b | ||
|
|
82cda0edaa | ||
|
|
9fe875355a | ||
|
|
5f21bd2036 | ||
|
|
5b5ea6d2d8 | ||
|
|
3dcfd23036 | ||
|
|
186914bcac | ||
|
|
4b3205fee8 | ||
|
|
e4453841db | ||
|
|
32db4cbd11 | ||
|
|
5558249cd3 | ||
|
|
86b636ae54 | ||
|
|
c9f7154524 | ||
|
|
d72f872369 | ||
|
|
b300011d07 | ||
|
|
3946253d6e | ||
|
|
11fc367845 | ||
|
|
bdc65cc250 | ||
|
|
70dad0f600 | ||
|
|
26e9aa244b | ||
|
|
913f77fd2f | ||
|
|
dba774e1c7 | ||
|
|
af09d652a3 | ||
|
|
0babdfbc44 | ||
|
|
f154d53be9 | ||
|
|
ed3cd2fe3b | ||
|
|
59912a00bc | ||
|
|
675d8b0179 | ||
|
|
a1ca296fc0 | ||
|
|
c1b1fe90fd | ||
|
|
30b5610a73 | ||
|
|
db9b9df8f7 | ||
|
|
ae6a75e913 | ||
|
|
37d72c1628 | ||
|
|
ab3e6bb37c | ||
|
|
9ddb899a1a | ||
|
|
1f0cda8aee | ||
|
|
90ae85317c | ||
|
|
a8ae891aff | ||
|
|
1f2df3e944 | ||
|
|
e39a432210 | ||
|
|
6b3bcee582 | ||
|
|
6c274b75b8 | ||
|
|
278032c94b | ||
|
|
23a8982f5c | ||
|
|
5df6c32d04 | ||
|
|
7cae330c3c | ||
|
|
100aaa4cee | ||
|
|
ead0b73e7b | ||
|
|
2ace846e38 | ||
|
|
1d98393f0c | ||
|
|
c48ee2a364 | ||
|
|
95d1e8432f | ||
|
|
4d59ac78bd | ||
|
|
f1143e134e | ||
|
|
e3e869faee | ||
|
|
8350e72393 | ||
|
|
15f74b9d97 | ||
|
|
55b54e24fe | ||
|
|
46b4ce81e4 | ||
|
|
7a723d03d0 | ||
|
|
9d6fbfd0d6 | ||
|
|
8fd6bf47b1 | ||
|
|
f25f3a34d0 | ||
|
|
2823848fae | ||
|
|
b9467b2bc3 | ||
|
|
fa190e0975 | ||
|
|
90dc8cf997 | ||
|
|
a6a6efdf59 | ||
|
|
8043faf6c7 | ||
|
|
ebed9e64ed | ||
|
|
913636ae44 | ||
|
|
7b064e9f33 | ||
|
|
16da0eaca3 | ||
|
|
6acfee2737 | ||
|
|
92abf7edaa | ||
|
|
395babc1f5 | ||
|
|
68cde54bad | ||
|
|
c7d7529c00 | ||
|
|
748f60c7bb | ||
|
|
ffee62e940 | ||
|
|
a0ea221131 | ||
|
|
2846bcb84e | ||
|
|
542ed6c08f | ||
|
|
6dabb848a5 | ||
|
|
de0181e0d9 | ||
|
|
a850d426ef | ||
|
|
771277d5d9 | ||
|
|
20b4f5c99f | ||
|
|
e7fa87b301 | ||
|
|
40c66958bc | ||
|
|
600fc71aed | ||
|
|
443e4b04cd | ||
|
|
556a3c0fab | ||
|
|
1eca52f962 | ||
|
|
e21adb395b | ||
|
|
0e4bf80bf4 | ||
|
|
107750971b | ||
|
|
24e1be47ca | ||
|
|
7e0978c764 | ||
|
|
83a0c9662d | ||
|
|
a4736a49f8 | ||
|
|
aa7d8630b5 | ||
|
|
36313f378e | ||
|
|
45054d3238 | ||
|
|
173e6b08c9 | ||
|
|
2a86404ba5 | ||
|
|
b8a0199cde | ||
|
|
a46ed55a86 | ||
|
|
1a9fbddef4 | ||
|
|
1209aaafb4 | ||
|
|
8e71261d49 | ||
|
|
49e45eab54 | ||
|
|
bd22793ceb | ||
|
|
b2013436c5 | ||
|
|
cc808cb8c5 | ||
|
|
afffc4b3f0 | ||
|
|
99710ef9dc | ||
|
|
3d4ae04d9b | ||
|
|
707b391449 | ||
|
|
45b843f344 | ||
|
|
191fd90f06 | ||
|
|
05920cd66d | ||
|
|
01508d5e42 | ||
|
|
57cf6cbc9e | ||
|
|
03ebbea46a | ||
|
|
8e7a5d1ec3 | ||
|
|
aa74a2535b | ||
|
|
32379ba4b7 | ||
|
|
7fe8d7b4be | ||
|
|
aace726a91 | ||
|
|
e6f6e5464a | ||
|
|
7d04b600fb | ||
|
|
463bfde2ac | ||
|
|
893f74dc3e | ||
|
|
8a215fbcc7 | ||
|
|
ac5e4c4722 | ||
|
|
04d01b0bab | ||
|
|
cb1d7565a3 | ||
|
|
1d9b6407a4 | ||
|
|
22ed522711 | ||
|
|
a3598ef859 | ||
|
|
6fe09ec2dd | ||
|
|
57911d9e09 | ||
|
|
3aee283495 | ||
|
|
54b24c2677 | ||
|
|
8cadad0a1e | ||
|
|
633c5f3cda | ||
|
|
a8431e2040 | ||
|
|
7a2b916387 | ||
|
|
f4dfb68b7b | ||
|
|
8f4f27d860 | ||
|
|
e9eab29953 | ||
|
|
4f1eeb7da5 | ||
|
|
6fd426d719 | ||
|
|
331da3c8c7 | ||
|
|
c48943bc67 | ||
|
|
7680b33b99 | ||
|
|
bb5d75bc7e | ||
|
|
528395e2c3 | ||
|
|
6a9012e5e4 | ||
|
|
6e8315ab40 | ||
|
|
7cb2699f19 | ||
|
|
4b5da81ba6 | ||
|
|
6aca9a9c99 | ||
|
|
fa195fde8e | ||
|
|
1057f2932b | ||
|
|
b9224fc379 | ||
|
|
57dd818170 | ||
|
|
49256ec1b7 | ||
|
|
f6f92e5403 | ||
|
|
613c311076 | ||
|
|
9ee7a7d9ff | ||
|
|
44f8aed06d | ||
|
|
d5d4a5c591 | ||
|
|
74381aa8c1 | ||
|
|
203ee9b623 | ||
|
|
6c8c49966a | ||
|
|
3862942e9f | ||
|
|
8571fc43b0 | ||
|
|
59aabd956d | ||
|
|
9b09ba4483 | ||
|
|
686c3f9208 | ||
|
|
8a7e2bd8e4 | ||
|
|
174c4ae749 | ||
|
|
67da9417a8 | ||
|
|
f4a4ced88d | ||
|
|
19b2d7af41 | ||
|
|
343d8a1c1e | ||
|
|
9480f8272a | ||
|
|
0d9e260a6a | ||
|
|
51bca25345 | ||
|
|
694396dcfb | ||
|
|
1cb53e943e | ||
|
|
13c8318adc | ||
|
|
48ed797055 | ||
|
|
3c2285141c | ||
|
|
49b884c0b1 | ||
|
|
24d20ca9ee | ||
|
|
6f02181024 | ||
|
|
ed3353a271 | ||
|
|
965f5f96b7 | ||
|
|
61d9df7f58 | ||
|
|
231a5169ec | ||
|
|
ce7b7b910a | ||
|
|
81a61558cb | ||
|
|
7d2bc2e885 | ||
|
|
89b237f6b4 | ||
|
|
a1bb132233 | ||
|
|
022d538940 | ||
|
|
ccf70a1b76 | ||
|
|
af3fe53ec8 | ||
|
|
e981fb159f | ||
|
|
79b9d55854 | ||
|
|
b2055a4457 | ||
|
|
1479f159aa | ||
|
|
7bf7e09002 | ||
|
|
de8d39ffed | ||
|
|
543a7e3962 | ||
|
|
9dfe3f5af8 | ||
|
|
f11b08d8cb | ||
|
|
45b6f08984 | ||
|
|
92a4676f8d | ||
|
|
7bf7c0bb06 | ||
|
|
8f8cf5b704 | ||
|
|
6be238430d | ||
|
|
f77216d8f4 | ||
|
|
d42a9b2409 | ||
|
|
6753834054 | ||
|
|
77a4a64eb4 | ||
|
|
49db40d557 | ||
|
|
0032c608f1 | ||
|
|
fa603b32b1 | ||
|
|
9105d4d14a | ||
|
|
c76db7d8d1 | ||
|
|
6f39e9136e | ||
|
|
1f1ba16ead | ||
|
|
b6edc5f574 | ||
|
|
8b0f138100 | ||
|
|
a810cd0b48 | ||
|
|
b17fec41b0 | ||
|
|
5fcfabf1d4 | ||
|
|
1d77b018bb | ||
|
|
f7d9d1b1af | ||
|
|
aaf0274fe9 | ||
|
|
c142af482b | ||
|
|
cd7c11c7d0 | ||
|
|
fb8f36eeec | ||
|
|
9a4f0f6ab1 | ||
|
|
bed52983a0 | ||
|
|
655ce2c2e8 | ||
|
|
f841568e5a | ||
|
|
469697845c | ||
|
|
273f3cbaf8 | ||
|
|
96f671908e | ||
|
|
f09aace765 | ||
|
|
772529e2fb | ||
|
|
eeff017b9f | ||
|
|
22394ebdeb | ||
|
|
ffbc9b22a7 | ||
|
|
c3a7562e2c | ||
|
|
605e0ea128 | ||
|
|
c89a83f980 | ||
|
|
cfad88666b | ||
|
|
f918dc24c6 | ||
|
|
e898993fe8 | ||
|
|
5cafcb4a01 | ||
|
|
03154cde88 | ||
|
|
a74bf7b63f | ||
|
|
0bc7169698 | ||
|
|
24b6e7a835 | ||
|
|
f59e33682c | ||
|
|
d811af37dd | ||
|
|
0e1a7d61f3 | ||
|
|
573637222d | ||
|
|
05e9d65251 | ||
|
|
0bbb20fee0 | ||
|
|
dbc5ea32ae | ||
|
|
0319604863 | ||
|
|
acf99f2441 | ||
|
|
cbf2a02877 | ||
|
|
226d9bd1ad | ||
|
|
48b3d30088 | ||
|
|
e311cfb8da | ||
|
|
4347fba80a | ||
|
|
7dc68ed368 | ||
|
|
a4aa133db5 | ||
|
|
6bbe523f6f | ||
|
|
fef1e35c54 | ||
|
|
20410aeb03 | ||
|
|
83ea27af3e | ||
|
|
b7bfd69788 | ||
|
|
99d663a6f2 | ||
|
|
1af11240a6 | ||
|
|
3e8e8690e3 | ||
|
|
be843300be | ||
|
|
dc77721c11 | ||
|
|
a38d081f17 | ||
|
|
1e09fa4642 | ||
|
|
bd8ff73f9e | ||
|
|
57620dc2ea | ||
|
|
b472fbb72f | ||
|
|
04321b16c1 | ||
|
|
7268824612 | ||
|
|
23c41a8059 | ||
|
|
fdc78faa13 | ||
|
|
c8af580866 | ||
|
|
348c97710f | ||
|
|
a854760d26 | ||
|
|
c7c39047de | ||
|
|
f8a7a0a9a2 | ||
|
|
2cb3dcf499 | ||
|
|
b36d32b66d | ||
|
|
639ea96bb9 | ||
|
|
565be77e96 | ||
|
|
ac6de0324c | ||
|
|
f46ae023ba | ||
|
|
212144feca | ||
|
|
3eed51fd3e | ||
|
|
4d713db546 | ||
|
|
0cd9f4615c | ||
|
|
cfbce0d737 | ||
|
|
1d9cd88dd7 | ||
|
|
0e62862119 | ||
|
|
6b6cdef42d | ||
|
|
33b7a605c0 | ||
|
|
e01803ce9d | ||
|
|
e3e5755903 | ||
|
|
61ce53b686 | ||
|
|
b029f77b6a | ||
|
|
0a8339a405 | ||
|
|
2264eaae18 | ||
|
|
38f91da5af | ||
|
|
8edea3aee5 | ||
|
|
08b6e7713e | ||
|
|
6a89814b83 | ||
|
|
52d3aa9315 | ||
|
|
4e264c503a | ||
|
|
32051e9ca0 | ||
|
|
2b0079580b | ||
|
|
03ce030e7c | ||
|
|
59097e2466 | ||
|
|
2ce161e585 | ||
|
|
33306a5aff | ||
|
|
6e03de5011 | ||
|
|
713281a8ba | ||
|
|
42e3de9e2c | ||
|
|
1e029dbc9a | ||
|
|
81bb7456f8 | ||
|
|
26f8d2aa30 | ||
|
|
a8b848d367 | ||
|
|
482ce981ce | ||
|
|
97ad93ab1f | ||
|
|
319b7fcdaa | ||
|
|
4de8220ce6 | ||
|
|
9114692f95 | ||
|
|
0f7731b611 | ||
|
|
8040bc1a22 | ||
|
|
11d273cbb6 | ||
|
|
a4b8f8f795 | ||
|
|
dfa2ac3435 | ||
|
|
39820c783c | ||
|
|
66822a5f95 | ||
|
|
eb4f1f8164 | ||
|
|
6a85b3ab84 | ||
|
|
c57592f3a6 | ||
|
|
b0d7b177be | ||
|
|
566d3bdf7c | ||
|
|
adbf793e79 | ||
|
|
cf6c4c9aae | ||
|
|
ffaf85c8f3 | ||
|
|
2cc78f925f | ||
|
|
93f0eea4e7 | ||
|
|
fddfc6d494 | ||
|
|
58e3941810 | ||
|
|
3ad433a50b | ||
|
|
8ac2db5b7c | ||
|
|
a441dcebae | ||
|
|
ff4704d5ea | ||
|
|
6ddfbd84b0 | ||
|
|
1e6417f95b | ||
|
|
e147a9d2fd | ||
|
|
4efebb8c0b | ||
|
|
9ce0df88ae | ||
|
|
3cf0c0eb04 | ||
|
|
ab7e363279 | ||
|
|
df655015b1 | ||
|
|
9fc594434f | ||
|
|
b93e22c45a | ||
|
|
193f5b6512 | ||
|
|
cb3f96683f | ||
|
|
06ffdf164a | ||
|
|
5b75e5e917 | ||
|
|
fad0b49841 | ||
|
|
260e45cd7b | ||
|
|
1575619d53 | ||
|
|
d5a36a167d | ||
|
|
fc9ea347ca | ||
|
|
1a9b0509d5 | ||
|
|
07b64ad384 | ||
|
|
1bc5d2986b | ||
|
|
bb36cca315 | ||
|
|
b8bf482c6a | ||
|
|
3b673ca915 | ||
|
|
bbf0287511 | ||
|
|
d4786f3f75 | ||
|
|
b333fd676d | ||
|
|
f1051c8773 | ||
|
|
243f3a201d | ||
|
|
2f7de243f6 | ||
|
|
1aafb43103 | ||
|
|
8ccd01ade5 | ||
|
|
e6bfbce6cb | ||
|
|
1f63aed64c | ||
|
|
253b0da7d2 | ||
|
|
33a4d524db | ||
|
|
bbd8a53d9d | ||
|
|
8554924cb9 | ||
|
|
fee811d950 | ||
|
|
78076e70b4 | ||
|
|
b78bc65ce7 | ||
|
|
b1fb623113 | ||
|
|
de14908fd3 | ||
|
|
5c3972196a | ||
|
|
3230781538 | ||
|
|
e7fd0e3b6a | ||
|
|
2f7d1f009d | ||
|
|
946f91f387 | ||
|
|
986eacae9a | ||
|
|
97b8e28a2b | ||
|
|
c0962fec18 | ||
|
|
8ba7bdf2bd | ||
|
|
b015fbc9fc | ||
|
|
b69320c070 | ||
|
|
dcccc213ce | ||
|
|
c0e601c020 | ||
|
|
017ed1a9e1 | ||
|
|
b9aef618fe | ||
|
|
4102cc2211 | ||
|
|
ea776aa710 | ||
|
|
feb96dc8ea | ||
|
|
470fd43b49 | ||
|
|
76d106e474 | ||
|
|
78c3c9da6f | ||
|
|
081d08a7f8 | ||
|
|
96a10ca83f | ||
|
|
f31360ba4e | ||
|
|
4eb16d6d3a | ||
|
|
53304514b6 | ||
|
|
6fbf1387f5 | ||
|
|
e5c937396d | ||
|
|
af186f878d | ||
|
|
97894a940e | ||
|
|
48d70182b4 | ||
|
|
a1091bad46 | ||
|
|
9f9be21e2a | ||
|
|
a2bdcd6e49 | ||
|
|
11b2b5bcc2 | ||
|
|
ebef2c91e2 | ||
|
|
0c3edb10b0 | ||
|
|
f7bae8ac0f | ||
|
|
8ac97688da | ||
|
|
2efacb3e5b | ||
|
|
dae46abb71 | ||
|
|
5ee2a74e2d | ||
|
|
d0b8349a2d | ||
|
|
34c4ecb047 | ||
|
|
3d9dba965f | ||
|
|
024c01d350 | ||
|
|
2adb8acd80 | ||
|
|
a23bc4cb5c | ||
|
|
cf17a45d8d | ||
|
|
b46ebe756e | ||
|
|
e89cf400c0 | ||
|
|
9e67929ee7 | ||
|
|
90625782c0 | ||
|
|
84ef207d9c | ||
|
|
e68fff58e8 | ||
|
|
bf70539221 | ||
|
|
5949c73a30 | ||
|
|
cc6c9bf7a0 | ||
|
|
6b1aab9c67 | ||
|
|
8f4d3d418d | ||
|
|
3b3f8e4e44 | ||
|
|
f94ca10adf | ||
|
|
4471bc7a5d | ||
|
|
5689ac60ff | ||
|
|
045bc4e5ee | ||
|
|
17a18fc9ba | ||
|
|
41152d1722 | ||
|
|
f1af9f8f6e | ||
|
|
0517a7014f | ||
|
|
3e6d608a2f | ||
|
|
5de954baa4 | ||
|
|
add1e61b6f | ||
|
|
20c15f352f | ||
|
|
8aa7dabeff | ||
|
|
78c7c1589a | ||
|
|
dd833ca56b | ||
|
|
681c1dc267 | ||
|
|
4545422ee0 | ||
|
|
ca94ca906f | ||
|
|
76de450c71 | ||
|
|
dd2b1ed580 | ||
|
|
baec89f896 | ||
|
|
7c7051a11e | ||
|
|
aa27a2f7a1 | ||
|
|
9ee6d06d50 | ||
|
|
0cc1cb5a7b | ||
|
|
0a9fc4e02d | ||
|
|
be840460d8 | ||
|
|
cb45987fe2 | ||
|
|
5ffd7db8e0 | ||
|
|
14377705e7 | ||
|
|
b985ff0584 | ||
|
|
b148b8ec92 | ||
|
|
79de6bcbf7 | ||
|
|
14bd6dd25d | ||
|
|
7f269e0d69 | ||
|
|
4baec83db5 | ||
|
|
f7a4d13a4c | ||
|
|
0936861da1 | ||
|
|
3318d76bab | ||
|
|
8f3881eabf | ||
|
|
5252480b09 | ||
|
|
d18f56177c | ||
|
|
333998b518 | ||
|
|
3fb4fba1cb | ||
|
|
c7e6210c8d | ||
|
|
d864085c13 | ||
|
|
fcdc1a1602 | ||
|
|
bf7496f0d5 | ||
|
|
9700150653 | ||
|
|
f0e9e55af2 | ||
|
|
ff88274554 | ||
|
|
afce9dc5c0 | ||
|
|
b5700bd0b1 | ||
|
|
a8c4786311 | ||
|
|
2971ccc0b7 | ||
|
|
4a2c7b313f | ||
|
|
36bf6fc149 | ||
|
|
f46ec30995 | ||
|
|
9bd5a190c2 | ||
|
|
86da3cedc2 | ||
|
|
329a176a5c | ||
|
|
9379093a4f | ||
|
|
c9014d0338 | ||
|
|
ed26dca64e | ||
|
|
08c6496e24 | ||
|
|
a8c5df38e9 | ||
|
|
71e8d12b70 | ||
|
|
d216fdca44 | ||
|
|
384c5d7dbb | ||
|
|
b0ad08dbbc | ||
|
|
3600603016 | ||
|
|
cde1a85394 | ||
|
|
c623604ea9 | ||
|
|
7933da8f66 | ||
|
|
ecf518bb02 | ||
|
|
1d9f1be004 | ||
|
|
44138f578f | ||
|
|
0e3abc2b53 | ||
|
|
a96be3db98 | ||
|
|
d6f239c564 | ||
|
|
166e9a0e82 | ||
|
|
06ccad9db0 | ||
|
|
18722ce434 | ||
|
|
a0feeb1966 | ||
|
|
df6341f0b8 | ||
|
|
dbc0b7e4a9 | ||
|
|
aa2907323f | ||
|
|
96d2bc52e9 | ||
|
|
e91e4e0eea | ||
|
|
c2b5f6497d | ||
|
|
3c2d325d8c | ||
|
|
a77bb8e68d | ||
|
|
870cd86393 | ||
|
|
0b650367f3 | ||
|
|
1616a06bfd | ||
|
|
3f0d30897f | ||
|
|
66c1d390b9 | ||
|
|
03ceb2aac5 | ||
|
|
39888cebaa | ||
|
|
efb69fc75f | ||
|
|
711a179c69 | ||
|
|
3133f8f8b9 | ||
|
|
f0a43912d1 | ||
|
|
b92ac5c36d | ||
|
|
504070f3eb | ||
|
|
feab6b3b30 | ||
|
|
2d4ac33046 | ||
|
|
17e2a02281 | ||
|
|
096545c5a1 | ||
|
|
04dff33ac5 | ||
|
|
cfa18c2b8e | ||
|
|
13354955db | ||
|
|
bb683804f4 | ||
|
|
e2a577de24 | ||
|
|
89d963c81a | ||
|
|
b37ed48b9d | ||
|
|
4b9cfcb867 | ||
|
|
f86c9f90ad | ||
|
|
31a6ab59fb | ||
|
|
34789645f7 | ||
|
|
f380452ef0 | ||
|
|
decd9c55e5 | ||
|
|
e815f0ff89 | ||
|
|
5ea6b4a8d8 | ||
|
|
a53ca3faf1 | ||
|
|
86fdd6c93b | ||
|
|
0e0661fe02 | ||
|
|
86ce65e0ea | ||
|
|
c3eb4fab13 | ||
|
|
7a476372e1 | ||
|
|
5e421ce607 | ||
|
|
1d8676f456 | ||
|
|
0c3dd92592 | ||
|
|
d33261d0c2 | ||
|
|
7041c6a106 | ||
|
|
12ffb310bf | ||
|
|
d9799fec70 | ||
|
|
4570d5b54b | ||
|
|
d49e3e81a4 | ||
|
|
579b1b486c | ||
|
|
eba372062e | ||
|
|
c9ccd2bbab | ||
|
|
5aff8bac10 | ||
|
|
1a12687027 | ||
|
|
a221d2b491 | ||
|
|
ccd99eb24d | ||
|
|
c5c6760b5d | ||
|
|
c07281bf41 | ||
|
|
24bad09c74 | ||
|
|
cddf71e600 | ||
|
|
9f82052c71 | ||
|
|
e0eb57d38d | ||
|
|
b6b97accb1 | ||
|
|
799934171a | ||
|
|
10ebf03698 | ||
|
|
cd258fbb52 | ||
|
|
6cba181fad | ||
|
|
f9504cece3 | ||
|
|
182f1c9da8 | ||
|
|
e7b009786b | ||
|
|
5262233312 | ||
|
|
a9f9031f49 | ||
|
|
061fcf926d | ||
|
|
7e7a5f3fd4 | ||
|
|
f8bb396dc4 | ||
|
|
a832edce0d | ||
|
|
67d1f299b3 | ||
|
|
cb7c0ecaa2 | ||
|
|
e225159cce | ||
|
|
195ceec9b4 | ||
|
|
5bd73867b6 | ||
|
|
22e6f28464 | ||
|
|
5611f6c7f5 | ||
|
|
3f0f4d50a1 | ||
|
|
2d0859a727 | ||
|
|
509b268fba | ||
|
|
c3129663eb | ||
|
|
816d1f5a69 | ||
|
|
8cc903c0e1 | ||
|
|
13087a10b7 | ||
|
|
27a3888e35 | ||
|
|
fb0ec05ff8 | ||
|
|
76c4aa2c55 | ||
|
|
e1c503e51e | ||
|
|
5965755caa | ||
|
|
1ad54561b0 | ||
|
|
42e322012c | ||
|
|
8cf6e8ec75 | ||
|
|
79d4d2fb22 | ||
|
|
b785bc5704 | ||
|
|
6719e2679f | ||
|
|
cb9c8db5ba | ||
|
|
a2637313f8 | ||
|
|
467689525f | ||
|
|
1a04a05b79 | ||
|
|
b8bffd884c | ||
|
|
624beb6fa0 | ||
|
|
7606b86854 | ||
|
|
e10cbe23b9 | ||
|
|
d34838bdd8 | ||
|
|
c979c23e6b | ||
|
|
516d0b8dc8 | ||
|
|
99c7fd72f8 | ||
|
|
2828e7a7b6 | ||
|
|
16162c01ed | ||
|
|
bbbdbfa912 | ||
|
|
06cccc53fa | ||
|
|
801c640724 | ||
|
|
081d2aed23 | ||
|
|
4d81b9d18d | ||
|
|
e9dc613548 | ||
|
|
3af22f7e91 | ||
|
|
2135cb8ece | ||
|
|
376aad5d26 | ||
|
|
27ba45aa75 | ||
|
|
de17007788 | ||
|
|
e86a7906fe | ||
|
|
4a8e6abc06 | ||
|
|
2eb7fed9f4 | ||
|
|
ff8d5ca351 | ||
|
|
07c4ad05f4 | ||
|
|
548c2e5500 | ||
|
|
ec2c6eff6f | ||
|
|
15fe82c62f | ||
|
|
cb5ecf4a8a |
3
.changeset/.prettierrc.json
Normal file
3
.changeset/.prettierrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
7
.changeset/fancy-ears-walk.md
Normal file
7
.changeset/fancy-ears-walk.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
'@vben/styles': patch
|
||||||
|
'@vben-core/form-ui': patch
|
||||||
|
'@vben/web-naive': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
|
||||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
|
|
||||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
|
||||||
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
|
|
||||||
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
|
||||||
@@ -107,6 +107,37 @@ alwaysApply: true
|
|||||||
|
|
||||||
- 上一部分代码和下一部分代码中间空行不超过 2 行
|
- 上一部分代码和下一部分代码中间空行不超过 2 行
|
||||||
|
|
||||||
|
## VIP 功能判断(PC 管理端强制)
|
||||||
|
|
||||||
|
- **判断方法**:统一用 `#/utils/vip` 的 `hasVipPermission` / `useVipPermission` / `VIP_FEATURE` 常量
|
||||||
|
- **显隐组件**:模板包一层 `<VipGate code="medical_record" :vip="receptionVip">`(`#/components/vip/VipGate.vue`)
|
||||||
|
- 接诊/复诊/开方:**必须传履约诊所 vip**(`get-current-store-type` 的 `vip`),禁止只靠登录态判断门店权益
|
||||||
|
- 显式传 `null` 表示「尚无履约诊所权益」,**不会**回落登录态;不传第二参才用 `userInfo.vip`
|
||||||
|
- 禁止在业务页再手写 `permissions.indexOf('medical_record')` 或复制一套 VIP 拉取逻辑
|
||||||
|
|
||||||
|
## 暗色模式适配(PC 管理端强制)
|
||||||
|
|
||||||
|
后台支持亮/暗主题切换,**新增或改样式时必须同时适配暗色**,禁止只按白底写死颜色。
|
||||||
|
|
||||||
|
### 优先用法(推荐)
|
||||||
|
|
||||||
|
- 颜色、边框、背景一律用主题 CSS 变量,例如:
|
||||||
|
- 文字:`hsl(var(--foreground))` / `hsl(var(--muted-foreground))`
|
||||||
|
- 背景:`hsl(var(--background))` / `hsl(var(--card, var(--background)))` / `hsl(var(--muted) / 0.25)`
|
||||||
|
- 边框:`hsl(var(--border))`
|
||||||
|
- 强调/选中:`hsl(var(--primary))`、`hsl(var(--primary) / 0.1)`
|
||||||
|
- 警告:`hsl(var(--warning))`、`hsl(var(--warning) / 0.12)`(参考 `AiDisclaimerBanner.vue`)
|
||||||
|
- **禁止**业务样式写死 `#fff`、`#000`、`#0f172a`、`#f8fafc`、`#64748b` 等仅适合亮色的色值
|
||||||
|
- **禁止**用 `:global(.dark)` 改全局变量以免污染整站配色
|
||||||
|
- 优先「一套变量样式自动跟主题」,避免再写一套 `.dark .xxx { ... }`;仅当变量无法表达(如图片反色、特殊阴影)才用局部 `.dark .xxx`(且必须 `scoped`)
|
||||||
|
|
||||||
|
### 自检清单
|
||||||
|
|
||||||
|
- 弹窗 / 抽屉 / 卡片 / 预览对照区:在暗色下背景、文字、边框对比度可读
|
||||||
|
- 选中态、成功/导入高亮:用 `--primary` 透明度,不要写死浅绿底 `#f0faf8`
|
||||||
|
- 表格空态、分割线、次要文案:用 `--muted` / `--muted-foreground` / `--border`
|
||||||
|
- 改完后切换暗色主题肉眼过一遍,不要只测亮色
|
||||||
|
|
||||||
## Vben 组件使用规范
|
## Vben 组件使用规范
|
||||||
|
|
||||||
### VbenModal(来自 @vben/common-ui)
|
### VbenModal(来自 @vben/common-ui)
|
||||||
@@ -145,6 +176,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
|
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
|
||||||
- 动态修改 schema 必须用 `formApi.updateSchema([...])`,**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
|
- 动态修改 schema 必须用 `formApi.updateSchema([...])`,**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
|
||||||
|
- 重置表单用 `formApi.resetForm()`,**严禁 `formApi.resetFields()`**(那是 antd Form 的 API,Vben Form 没有,会报 `resetFields is not a function`;已踩过坑)
|
||||||
- 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集
|
- 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集
|
||||||
- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` 或 `formApi.validateAndSubmitForm()`
|
- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` 或 `formApi.validateAndSubmitForm()`
|
||||||
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
|
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
|
||||||
@@ -152,82 +184,3 @@ alwaysApply: true
|
|||||||
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
|
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
|
||||||
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
|
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
|
||||||
|
|
||||||
# 专项模块规范
|
|
||||||
|
|
||||||
## OA 通知模块前端规则
|
|
||||||
|
|
||||||
### 组件与封装复用
|
|
||||||
|
|
||||||
- 所有 OA 配置入口(系统配置 tab、CRUD 页面、员工 OA 账号弹窗)必须复用 `Tabs.TabPane`、`useVbenVxeGrid`、`useVbenModal`、`useVbenForm`,禁止用原生 antd 表格/表单
|
|
||||||
|
|
||||||
### 平台动态化(禁止写死)
|
|
||||||
|
|
||||||
- **平台列表必须从接口 `oa-platform/list` 动态拉取**,禁止在前端写死 `['work_wechat', 'dingtalk', 'feishu']` 常量数组
|
|
||||||
- 系统配置 tab 中的平台开关行、机器人表单中的平台下拉、场景表单中的机器人分组,都要走接口动态渲染
|
|
||||||
- 平台编码统一为字符串 snake_case(如 `work_wechat`、`dingtalk`、`feishu`),与后端 `xk_oa_platform.platform_code` 字段对齐
|
|
||||||
|
|
||||||
### 系统配置 tab 交互
|
|
||||||
|
|
||||||
- 系统配置 tab 中的「总开关关闭」必须禁用下方所有平台子开关(视觉上置灰),禁止总开关关闭时仍能切换子开关
|
|
||||||
- 平台子开关切换必须立即调 `oa-platform/update-enabled` 接口,**禁止依赖保存按钮批量提交**(避免总开关与子开关状态不一致)
|
|
||||||
- 系统配置页的总开关走现有 `getSystemConfigList` / `saveSystemConfig` 接口(config_key = `oa_notify_enabled`),**禁止新建专用接口**
|
|
||||||
|
|
||||||
### 表单字段约定
|
|
||||||
|
|
||||||
- @ 人配置必须按平台分组(用 Tabs 或 Collapse 分组),员工必须从 `xk_admin` 表选择(复用员工 picker 组件)
|
|
||||||
- OA 机器人表单中的 webhook、secret 字段必须用密码型输入框(`InputPassword`),禁止明文展示
|
|
||||||
- OA 场景表单中的机器人多选必须按平台分组展示(用 `Tabs` 或 `Collapse` 分组)
|
|
||||||
|
|
||||||
### 消息类型全量支持(数据库驱动 schema)
|
|
||||||
|
|
||||||
- 场景弹窗每个平台 Tab 内的消息类型选项**必须**通过 `GET /oa-scene/message-types` 接口动态拉取,
|
|
||||||
按 `platform_code` 过滤当前平台支持的所有 `message_type`
|
|
||||||
- 每个消息类型对应的 payload 表单**必须**按后端返回的 `payload_schema`(JSON 字符串)动态渲染,
|
|
||||||
禁止前端写死 schema(数据库可启停类型,前端硬编码会不同步)
|
|
||||||
- payload 字段渲染规则(与后端 `payload_schema` 约定):
|
|
||||||
- `Textarea`:多行文本
|
|
||||||
- `Input` / `InputNumber`:单行文本/数字
|
|
||||||
- `RadioGroup` / `Select`:选项组
|
|
||||||
- `GalleryPickLink`:素材库选择器,透传 `props.acceptTypes: number[]` 过滤文件类型
|
|
||||||
- `TemplateCardEditor`:企微模板卡片专用编辑器(用 `views/system/oa-scene/components/template-card-form.vue`)
|
|
||||||
- 提交结构:`message_config: { platform_code: { message_type, payload } }`
|
|
||||||
|
|
||||||
### image-gallery-picker 文件类型过滤
|
|
||||||
|
|
||||||
- `image-gallery-picker.vue` 和 `gallery-pick-link.vue` 都支持 `acceptTypes?: number[]` prop
|
|
||||||
- 取值对应 `xk_file_type.value`:1=图片、2=视频、3=音频、6=文件 等
|
|
||||||
- 不传 `acceptTypes` 时显示全部类型(默认行为,保持向后兼容)
|
|
||||||
- 传数组时只展示匹配类型 tab,且自动锁定到第一个匹配类型
|
|
||||||
- OA 场景中图片字段必须传 `[1]`、语音字段传 `[3]`、文件字段传 `[6]`,避免用户选错类型
|
|
||||||
|
|
||||||
### 通用拖拽可视化编辑器(`components/oa-template-card-editor/`)
|
|
||||||
|
|
||||||
- 任何「左侧组件库 → 右侧画布拖拽组装 → 自动生成 JSON」的需求,**必须复用此通用组件**,禁止重复造轮子
|
|
||||||
- schema 驱动:新增块类型只需扩展 `BlockSchema[]` 配置,禁止改组件内部
|
|
||||||
- SortableJS 用法参照 `views/system/role/components/quick-nav-transfer.vue`(动态 import `sortablejs/modular/sortable.complete.esm.js`)
|
|
||||||
- 双向绑定走 `v-model`(`modelValue` + `update:modelValue`),禁止内部直接修改 props
|
|
||||||
- OA 场景中的企微模板卡片必须用 `views/system/oa-scene/components/template-card-form.vue`(在通用编辑器之上做 OA 封装)
|
|
||||||
|
|
||||||
### 测试发送(全类型循环)
|
|
||||||
|
|
||||||
- OA 机器人 modal 的「测试发送」必须循环跑该平台支持的所有消息类型(单平台最多 8 种)
|
|
||||||
- 后端返回 `results` 数组,前端必须遍历分类型展示成功/失败(不要只展示第一条)
|
|
||||||
- 测试按钮 loading 用独立状态(不要复用 modal 的 `confirmLoading`)
|
|
||||||
- 测试发送结果以列表形式展示在表单下方,每种类型显示状态/耗时/错误信息
|
|
||||||
- 测试发送支持两套入口(共用 `testSendOaRobot` API):
|
|
||||||
- **表单弹窗内测试**(modal.vue):用于「保存前验证密钥」,直接传 webhook_url/secret(不带 id)
|
|
||||||
- **行级测试**(独立组件 `test-send-modal.vue`):用于列表中已有机器人,仅传 `id`,后端自动取已加密密钥
|
|
||||||
- 行级测试弹窗入参:`test_content` + `test_at_all` 开关 + `test_mobiles` 手机号多选
|
|
||||||
- 飞书 webhook 不支持手机号 @,前端必须在飞书平台下隐藏手机号字段并提示用户(仅 @all 有效)
|
|
||||||
|
|
||||||
### 群聊绑定(按平台过滤的 N:N 多选)
|
|
||||||
|
|
||||||
- 「OA群聊管理」是独立菜单(pid=232 OA 通知父级下,菜单 id=233,路由 `/system/oa-chat`)
|
|
||||||
- 群聊管理页结构必须与 `oa-robot` 一致:`index.vue + api/index.ts + config/{table,search,form}.ts + components/modal.vue`
|
|
||||||
- 机器人表单中的「绑定群聊」字段必须用 `Checkbox.Group`:
|
|
||||||
- 群聊列表通过 `getOaChatListByPlatform()` 一次性拉取全量(按平台分组的对象)
|
|
||||||
- 前端按当前 `platform_code` 过滤展示(平台切换时自动过滤掉不属于当前平台的勾选)
|
|
||||||
- 平台切换通过 `componentProps.onChange` 监听(vben form 字段值变化无现成 watch,必须通过 schema 注入 onChange 回调)
|
|
||||||
- 编辑场景下,机器人详情接口返回 `chat_ids: number[]`,前端 `selectedChatIds` 直接回显
|
|
||||||
- 提交时把 `chat_ids` 数组合并到 payload(后端 `OaRobotController::notRequest` 已包含该字段)
|
|
||||||
- 列表展示用后端拼接好的 `chat_names` 字符串(按「、」拼接),详情接口才返回 `chat_ids` 数组
|
|
||||||
|
|||||||
16
.gitattributes
vendored
16
.gitattributes
vendored
@@ -4,8 +4,18 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|
||||||
# Declare files that will always have CRLF line endings on checkout.
|
# Declare files that will always have CRLF line endings on checkout.
|
||||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
*.cmd text eol=crlf
|
||||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
*.[cC][mM][dD] text eol=crlf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.[bB][aA][tT] text eol=crlf
|
||||||
|
|
||||||
# Denote all files that are truly binary and should not be modified.
|
# Denote all files that are truly binary and should not be modified.
|
||||||
*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary
|
*.ico binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.svg binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
18
.github/CODEOWNERS
vendored
18
.github/CODEOWNERS
vendored
@@ -1,14 +1,14 @@
|
|||||||
# default onwer
|
# default onwer
|
||||||
* anncwb@126.com vince292007@gmail.com netfan@foxmail.com
|
* anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||||
|
|
||||||
# vben core onwer
|
# vben core onwer
|
||||||
/.github/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com
|
/.github/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||||
/.vscode/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com
|
/.vscode/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||||
/packages/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com
|
/packages/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||||
/packages/@core/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com
|
/packages/@core/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||||
/internal/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com
|
/internal/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||||
/scripts/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com
|
/scripts/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||||
|
|
||||||
# vben team onwer
|
# vben team onwer
|
||||||
apps/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5
|
apps/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 jinmao88@qq.com
|
||||||
docs/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5
|
docs/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 jinmao88@qq.com
|
||||||
|
|||||||
4
.github/actions/setup-node/action.yml
vendored
4
.github/actions/setup-node/action.yml
vendored
@@ -6,10 +6,10 @@ runs:
|
|||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
3
.github/config.yml
vendored
3
.github/config.yml
vendored
@@ -21,12 +21,10 @@ newPRWelcomeComment: |
|
|||||||
firstPRMergeComment: >
|
firstPRMergeComment: >
|
||||||
Thanks for your contribution! 🎉🎉🎉
|
Thanks for your contribution! 🎉🎉🎉
|
||||||
|
|
||||||
|
|
||||||
# Comment to be posted to on first time issues
|
# Comment to be posted to on first time issues
|
||||||
newIssueWelcomeComment: >
|
newIssueWelcomeComment: >
|
||||||
Thanks for opening your first issue! Be sure to follow the issue template and provide every bit of information to help the developers!
|
Thanks for opening your first issue! Be sure to follow the issue template and provide every bit of information to help the developers!
|
||||||
|
|
||||||
|
|
||||||
# *OPTIONAL* default titles to check against for lack of descriptiveness
|
# *OPTIONAL* default titles to check against for lack of descriptiveness
|
||||||
# MUST BE ALL LOWERCASE
|
# MUST BE ALL LOWERCASE
|
||||||
requestInfoDefaultTitles:
|
requestInfoDefaultTitles:
|
||||||
@@ -36,4 +34,3 @@ requestInfoDefaultTitles:
|
|||||||
# *Required* Comment to reply with
|
# *Required* Comment to reply with
|
||||||
requestInfoReplyComment: >
|
requestInfoReplyComment: >
|
||||||
Thanks for filing this issue/PR! It would be much appreciated if you could provide us with more information so we can effectively analyze the situation in context.
|
Thanks for filing this issue/PR! It would be much appreciated if you could provide us with more information so we can effectively analyze the situation in context.
|
||||||
|
|
||||||
|
|||||||
2
.github/contributing.md
vendored
2
.github/contributing.md
vendored
@@ -19,11 +19,9 @@ Project maintainers have the right and responsibility to remove, edit, or reject
|
|||||||
- Checkout a topic branch from the relevant branch, e.g. main, and merge back against that branch.
|
- Checkout a topic branch from the relevant branch, e.g. main, and merge back against that branch.
|
||||||
|
|
||||||
- If adding a new feature:
|
- If adding a new feature:
|
||||||
|
|
||||||
- Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it.
|
- Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it.
|
||||||
|
|
||||||
- If fixing bug:
|
- If fixing bug:
|
||||||
|
|
||||||
- Provide a detailed description of the bug in the PR. Live demo preferred.
|
- Provide a detailed description of the bug in the PR. Live demo preferred.
|
||||||
|
|
||||||
- It's OK to have multiple small commits as you work on the PR - GitHub can automatically squash them before merging.
|
- It's OK to have multiple small commits as you work on the PR - GitHub can automatically squash them before merging.
|
||||||
|
|||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- windows-latest
|
- windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/changeset-version.yml
vendored
2
.github/workflows/changeset-version.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -28,12 +28,12 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
- windows-latest
|
- windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -57,11 +57,11 @@ jobs:
|
|||||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
@@ -89,6 +89,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v4
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
37
.github/workflows/deploy.yml
vendored
37
.github/workflows/deploy.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
run: pnpm build:play
|
run: pnpm build:play
|
||||||
|
|
||||||
- name: Sync Playground files
|
- name: Sync Playground files
|
||||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
|
||||||
with:
|
with:
|
||||||
server: ${{ secrets.PRO_FTP_HOST }}
|
server: ${{ secrets.PRO_FTP_HOST }}
|
||||||
username: ${{ secrets.WEB_PLAYGROUND_FTP_ACCOUNT }}
|
username: ${{ secrets.WEB_PLAYGROUND_FTP_ACCOUNT }}
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
run: pnpm build:docs
|
run: pnpm build:docs
|
||||||
|
|
||||||
- name: Sync Docs files
|
- name: Sync Docs files
|
||||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
|
||||||
with:
|
with:
|
||||||
server: ${{ secrets.PRO_FTP_HOST }}
|
server: ${{ secrets.PRO_FTP_HOST }}
|
||||||
username: ${{ secrets.WEBSITE_FTP_ACCOUNT }}
|
username: ${{ secrets.WEBSITE_FTP_ACCOUNT }}
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
run: pnpm run build:antd
|
run: pnpm run build:antd
|
||||||
|
|
||||||
- name: Sync files
|
- name: Sync files
|
||||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
|
||||||
with:
|
with:
|
||||||
server: ${{ secrets.PRO_FTP_HOST }}
|
server: ${{ secrets.PRO_FTP_HOST }}
|
||||||
username: ${{ secrets.WEB_ANTD_FTP_ACCOUNT }}
|
username: ${{ secrets.WEB_ANTD_FTP_ACCOUNT }}
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
run: pnpm run build:ele
|
run: pnpm run build:ele
|
||||||
|
|
||||||
- name: Sync files
|
- name: Sync files
|
||||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
|
||||||
with:
|
with:
|
||||||
server: ${{ secrets.PRO_FTP_HOST }}
|
server: ${{ secrets.PRO_FTP_HOST }}
|
||||||
username: ${{ secrets.WEB_ELE_FTP_ACCOUNT }}
|
username: ${{ secrets.WEB_ELE_FTP_ACCOUNT }}
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -147,9 +147,26 @@ jobs:
|
|||||||
run: pnpm run build:naive
|
run: pnpm run build:naive
|
||||||
|
|
||||||
- name: Sync files
|
- name: Sync files
|
||||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
|
||||||
with:
|
with:
|
||||||
server: ${{ secrets.PRO_FTP_HOST }}
|
server: ${{ secrets.PRO_FTP_HOST }}
|
||||||
username: ${{ secrets.WEB_NAIVE_FTP_ACCOUNT }}
|
username: ${{ secrets.WEB_NAIVE_FTP_ACCOUNT }}
|
||||||
password: ${{ secrets.WEB_NAIVE_FTP_PASSWORD }}
|
password: ${{ secrets.WEB_NAIVE_FTP_PASSWORD }}
|
||||||
local-dir: ./apps/web-naive/dist/
|
local-dir: ./apps/web-naive/dist/
|
||||||
|
|
||||||
|
rerun-on-failure:
|
||||||
|
name: Rerun on failure
|
||||||
|
needs:
|
||||||
|
- deploy-playground-ftp
|
||||||
|
- deploy-docs-ftp
|
||||||
|
- deploy-antd-ftp
|
||||||
|
- deploy-ele-ftp
|
||||||
|
- deploy-naive-ftp
|
||||||
|
if: failure() && fromJSON(github.run_attempt) < 10
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Retry ${{ fromJSON(github.run_attempt) }} of 10
|
||||||
|
env:
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: gh workflow run rerun.yml -F run_id=${{ github.run_id }}
|
||||||
|
|||||||
2
.github/workflows/draft.yml
vendored
2
.github/workflows/draft.yml
vendored
@@ -20,6 +20,6 @@ jobs:
|
|||||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: release-drafter/release-drafter@v6
|
- uses: release-drafter/release-drafter@v7
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.github/workflows/issue-close-require.yml
vendored
2
.github/workflows/issue-close-require.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# 关闭未活动的 Issues
|
# 关闭未活动的 Issues
|
||||||
- name: Close Inactive Issues
|
- name: Close Inactive Issues
|
||||||
uses: actions/stale@v9
|
uses: actions/stale@v11
|
||||||
with:
|
with:
|
||||||
days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
|
days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
|
||||||
stale-issue-label: needs-reproduction # Label that flags an issue as stale.
|
stale-issue-label: needs-reproduction # Label that flags an issue as stale.
|
||||||
|
|||||||
6
.github/workflows/issue-labeled.yml
vendored
6
.github/workflows/issue-labeled.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: remove enhancement pending
|
- name: remove enhancement pending
|
||||||
if: github.event.label.name == 'enhancement'
|
if: github.event.label.name == 'enhancement'
|
||||||
uses: actions-cool/issues-helper@v3
|
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
|
||||||
with:
|
with:
|
||||||
actions: 'remove-labels'
|
actions: 'remove-labels'
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
- name: remove bug pending
|
- name: remove bug pending
|
||||||
if: github.event.label.name == 'bug'
|
if: github.event.label.name == 'bug'
|
||||||
uses: actions-cool/issues-helper@v3
|
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
|
||||||
with:
|
with:
|
||||||
actions: 'remove-labels'
|
actions: 'remove-labels'
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: needs reproduction
|
- name: needs reproduction
|
||||||
if: github.event.label.name == 'needs reproduction'
|
if: github.event.label.name == 'needs reproduction'
|
||||||
uses: actions-cool/issues-helper@v3
|
uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69
|
||||||
with:
|
with:
|
||||||
actions: 'create-comment, remove-labels'
|
actions: 'create-comment, remove-labels'
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v5
|
- uses: dessant/lock-threads@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
issue-inactive-days: '14'
|
issue-inactive-days: '14'
|
||||||
|
|||||||
78
.github/workflows/release-tag.yml
vendored
78
.github/workflows/release-tag.yml
vendored
@@ -3,78 +3,48 @@ name: Create Release Tag
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
env:
|
inputs:
|
||||||
HUSKY: '0'
|
tag:
|
||||||
|
description: 'Tag to create (e.g. v1.2.3)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [20]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# - name: Checkout code
|
- name: Extract version
|
||||||
# uses: actions/checkout@v4
|
|
||||||
# with:
|
|
||||||
# fetch-depth: 0
|
|
||||||
|
|
||||||
# - name: Install pnpm
|
|
||||||
# uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
# - name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
# uses: actions/setup-node@v4
|
|
||||||
# with:
|
|
||||||
# node-version: ${{ matrix.node-version }}
|
|
||||||
# cache: "pnpm"
|
|
||||||
|
|
||||||
# - name: Install dependencies
|
|
||||||
# run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# - name: Test and Build
|
|
||||||
# run: |
|
|
||||||
# pnpm run test
|
|
||||||
# pnpm run build
|
|
||||||
|
|
||||||
- name: version
|
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
tag=${GITHUB_REF/refs\/tags\//}
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
version=${tag#v}
|
raw_tag="${{ inputs.tag }}"
|
||||||
major=${version%%.*}
|
else
|
||||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
raw_tag="${GITHUB_REF_NAME}"
|
||||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
fi
|
||||||
echo "major=${major}" >> $GITHUB_OUTPUT
|
# Normalize: ensure v prefix
|
||||||
|
tag="${raw_tag}"
|
||||||
|
[[ "${tag:0:1}" != "v" ]] && tag="v${tag}"
|
||||||
|
version="${tag#v}"
|
||||||
|
major="${version%%.*}"
|
||||||
|
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "major=${major}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- uses: release-drafter/release-drafter@v6
|
- uses: release-drafter/release-drafter@v7
|
||||||
with:
|
with:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
publish: true
|
publish: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# - name: force update major tag
|
|
||||||
# run: |
|
|
||||||
# git tag v${{ steps.version.outputs.major }} ${{ steps.version.outputs.tag }} -f
|
|
||||||
# git push origin refs/tags/v${{ steps.version.outputs.major }} -f
|
|
||||||
|
|
||||||
# - name: Create Release for Tag
|
|
||||||
# id: release_tag
|
|
||||||
# uses: ncipollo/release-action@v1
|
|
||||||
# with:
|
|
||||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# generateReleaseNotes: "true"
|
|
||||||
# body: |
|
|
||||||
# > Please refer to [CHANGELOG.md](https://github.com/vbenjs/vue-vben-admin/blob/main/CHANGELOG.md) for details.
|
|
||||||
|
|||||||
19
.github/workflows/rerun.yml
vendored
Normal file
19
.github/workflows/rerun.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: Rerun workflow
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
run_id:
|
||||||
|
description: The workflow id to relanch
|
||||||
|
required: true
|
||||||
|
jobs:
|
||||||
|
rerun:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: rerun ${{ inputs.run_id }}
|
||||||
|
env:
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh run watch ${{ inputs.run_id }} > /dev/null 2>&1
|
||||||
|
gh run rerun ${{ inputs.run_id }} --failed
|
||||||
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Validate PR title
|
- name: Validate PR title
|
||||||
uses: amannn/action-semantic-pull-request@v5
|
uses: amannn/action-semantic-pull-request@v6
|
||||||
with:
|
with:
|
||||||
wip: true
|
wip: true
|
||||||
subjectPattern: ^(?![A-Z]).+$
|
subjectPattern: ^(?![A-Z]).+$
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v11
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -22,7 +22,8 @@ yarn.lock
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
**/backend-mock/data
|
**/backend-mock/data
|
||||||
|
.omx
|
||||||
|
.pnpm-store
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
@@ -51,3 +52,14 @@ vite.config.ts.*
|
|||||||
.history
|
.history
|
||||||
/.mimocode/
|
/.mimocode/
|
||||||
/.cursor/skills/
|
/.cursor/skills/
|
||||||
|
.cursor
|
||||||
|
|
||||||
|
# AI
|
||||||
|
.agent
|
||||||
|
.agents
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
skills-lock.json
|
||||||
|
.atomcode
|
||||||
|
datalog
|
||||||
|
.playwright-mcp
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ ports:
|
|||||||
- port: 5555
|
- port: 5555
|
||||||
onOpen: open-preview
|
onOpen: open-preview
|
||||||
tasks:
|
tasks:
|
||||||
- init: corepack enable && pnpm install
|
- init: npm i -g corepack && pnpm install
|
||||||
command: pnpm run dev:play
|
command: pnpm run dev:play
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
echo Start running commit-msg hook...
|
|
||||||
|
|
||||||
# Check whether the git commit information is standardized
|
|
||||||
pnpm exec commitlint --edit "$1"
|
|
||||||
|
|
||||||
echo Run commit-msg hook done.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 每次 git pull 之后, 安装依赖
|
|
||||||
|
|
||||||
pnpm install
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export default {
|
|
||||||
'*.{js,jsx,ts,tsx}': [
|
|
||||||
'prettier --cache --ignore-unknown --write',
|
|
||||||
'eslint --cache --fix',
|
|
||||||
],
|
|
||||||
'*.{scss,less,styl,html,vue,css}': [
|
|
||||||
'prettier --cache --ignore-unknown --write',
|
|
||||||
'stylelint --fix --allow-empty-input',
|
|
||||||
],
|
|
||||||
'*.md': ['prettier --cache --ignore-unknown --write'],
|
|
||||||
'*.vue': [
|
|
||||||
'prettier --write',
|
|
||||||
'eslint --cache --fix',
|
|
||||||
'stylelint --fix --allow-empty-input',
|
|
||||||
],
|
|
||||||
'{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': [
|
|
||||||
'prettier --cache --write--parser json',
|
|
||||||
],
|
|
||||||
'package.json': ['prettier --cache --write'],
|
|
||||||
};
|
|
||||||
@@ -1 +1 @@
|
|||||||
20.14.0
|
24.16.0
|
||||||
|
|||||||
14
.npmrc
14
.npmrc
@@ -1,13 +1 @@
|
|||||||
registry = "https://registry.npmmirror.com"
|
registry=https://registry.npmmirror.com
|
||||||
public-hoist-pattern[]=husky
|
|
||||||
public-hoist-pattern[]=eslint
|
|
||||||
public-hoist-pattern[]=prettier
|
|
||||||
public-hoist-pattern[]=prettier-plugin-tailwindcss
|
|
||||||
public-hoist-pattern[]=stylelint
|
|
||||||
public-hoist-pattern[]=*postcss*
|
|
||||||
public-hoist-pattern[]=@commitlint/*
|
|
||||||
public-hoist-pattern[]=czg
|
|
||||||
|
|
||||||
strict-peer-dependencies=false
|
|
||||||
auto-install-peers=true
|
|
||||||
dedupe-peer-dependents=true
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
dist
|
|
||||||
dev-dist
|
|
||||||
.local
|
|
||||||
.output.js
|
|
||||||
node_modules
|
|
||||||
.nvmrc
|
|
||||||
coverage
|
|
||||||
CODEOWNERS
|
|
||||||
.nitro
|
|
||||||
.output
|
|
||||||
|
|
||||||
|
|
||||||
**/*.svg
|
|
||||||
**/*.sh
|
|
||||||
|
|
||||||
public
|
|
||||||
.npmrc
|
|
||||||
*-lock.yaml
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '@vben/prettier-config';
|
|
||||||
@@ -2,3 +2,7 @@ dist
|
|||||||
public
|
public
|
||||||
__tests__
|
__tests__
|
||||||
coverage
|
coverage
|
||||||
|
.codex
|
||||||
|
.claude
|
||||||
|
.agent
|
||||||
|
.agents
|
||||||
|
|||||||
32
apps/backend-mock/api/demo/bigint.ts
Normal file
32
apps/backend-mock/api/demo/bigint.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { eventHandler, setHeader } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { unAuthorizedResponse } from '~/utils/response';
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
const data = `
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 123456789012345678901234567890123456789012345678901234567890,
|
||||||
|
"name": "John Doe",
|
||||||
|
"age": 30,
|
||||||
|
"email": "john-doe@demo.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 987654321098765432109876543210987654321098765432109876543210,
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"age": 25,
|
||||||
|
"email": "jane@demo.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
setHeader(event, 'Content-Type', 'application/json');
|
||||||
|
return data;
|
||||||
|
});
|
||||||
16
apps/backend-mock/api/system/dept/.post.ts
Normal file
16
apps/backend-mock/api/system/dept/.post.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import {
|
||||||
|
sleep,
|
||||||
|
unAuthorizedResponse,
|
||||||
|
useResponseSuccess,
|
||||||
|
} from '~/utils/response';
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
await sleep(600);
|
||||||
|
return useResponseSuccess(null);
|
||||||
|
});
|
||||||
16
apps/backend-mock/api/system/dept/[id].delete.ts
Normal file
16
apps/backend-mock/api/system/dept/[id].delete.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import {
|
||||||
|
sleep,
|
||||||
|
unAuthorizedResponse,
|
||||||
|
useResponseSuccess,
|
||||||
|
} from '~/utils/response';
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
return useResponseSuccess(null);
|
||||||
|
});
|
||||||
16
apps/backend-mock/api/system/dept/[id].put.ts
Normal file
16
apps/backend-mock/api/system/dept/[id].put.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import {
|
||||||
|
sleep,
|
||||||
|
unAuthorizedResponse,
|
||||||
|
useResponseSuccess,
|
||||||
|
} from '~/utils/response';
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
return useResponseSuccess(null);
|
||||||
|
});
|
||||||
62
apps/backend-mock/api/system/dept/list.ts
Normal file
62
apps/backend-mock/api/system/dept/list.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
const formatterCN = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateMockDataList(count: number) {
|
||||||
|
const dataList = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const dataItem: Record<string, any> = {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
pid: 0,
|
||||||
|
name: faker.commerce.department(),
|
||||||
|
status: faker.helpers.arrayElement([0, 1]),
|
||||||
|
createTime: formatterCN.format(
|
||||||
|
faker.date.between({ from: '2021-01-01', to: '2022-12-31' }),
|
||||||
|
),
|
||||||
|
remark: faker.lorem.sentence(),
|
||||||
|
};
|
||||||
|
if (faker.datatype.boolean()) {
|
||||||
|
dataItem.children = Array.from(
|
||||||
|
{ length: faker.number.int({ min: 1, max: 5 }) },
|
||||||
|
() => ({
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
pid: dataItem.id,
|
||||||
|
name: faker.commerce.department(),
|
||||||
|
status: faker.helpers.arrayElement([0, 1]),
|
||||||
|
createTime: formatterCN.format(
|
||||||
|
faker.date.between({ from: '2023-01-01', to: '2023-12-31' }),
|
||||||
|
),
|
||||||
|
remark: faker.lorem.sentence(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dataList.push(dataItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData = generateMockDataList(10);
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = structuredClone(mockData);
|
||||||
|
|
||||||
|
return useResponseSuccess(listData);
|
||||||
|
});
|
||||||
13
apps/backend-mock/api/system/menu/list.ts
Normal file
13
apps/backend-mock/api/system/menu/list.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { MOCK_MENU_LIST } from '~/utils/mock-data';
|
||||||
|
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess(MOCK_MENU_LIST);
|
||||||
|
});
|
||||||
29
apps/backend-mock/api/system/menu/name-exists.ts
Normal file
29
apps/backend-mock/api/system/menu/name-exists.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { eventHandler, getQuery } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { MOCK_MENU_LIST } from '~/utils/mock-data';
|
||||||
|
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
const namesMap: Record<string, any> = {};
|
||||||
|
|
||||||
|
function getNames(menus: any[]) {
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
namesMap[menu.name] = String(menu.id);
|
||||||
|
if (menu.children) {
|
||||||
|
getNames(menu.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getNames(MOCK_MENU_LIST);
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
const { id, name } = getQuery(event);
|
||||||
|
|
||||||
|
return (name as string) in namesMap &&
|
||||||
|
(!id || namesMap[name as string] !== String(id))
|
||||||
|
? useResponseSuccess(true)
|
||||||
|
: useResponseSuccess(false);
|
||||||
|
});
|
||||||
29
apps/backend-mock/api/system/menu/path-exists.ts
Normal file
29
apps/backend-mock/api/system/menu/path-exists.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { eventHandler, getQuery } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { MOCK_MENU_LIST } from '~/utils/mock-data';
|
||||||
|
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
const pathMap: Record<string, any> = { '/': 0 };
|
||||||
|
|
||||||
|
function getPaths(menus: any[]) {
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
pathMap[menu.path] = String(menu.id);
|
||||||
|
if (menu.children) {
|
||||||
|
getPaths(menu.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getPaths(MOCK_MENU_LIST);
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
const { id, path } = getQuery(event);
|
||||||
|
|
||||||
|
return (path as string) in pathMap &&
|
||||||
|
(!id || pathMap[path as string] !== String(id))
|
||||||
|
? useResponseSuccess(true)
|
||||||
|
: useResponseSuccess(false);
|
||||||
|
});
|
||||||
84
apps/backend-mock/api/system/role/list.ts
Normal file
84
apps/backend-mock/api/system/role/list.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { eventHandler, getQuery } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data';
|
||||||
|
import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
const formatterCN = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuIds = getMenuIds(MOCK_MENU_LIST);
|
||||||
|
|
||||||
|
function generateMockDataList(count: number) {
|
||||||
|
const dataList = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const dataItem: Record<string, any> = {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: faker.commerce.product(),
|
||||||
|
status: faker.helpers.arrayElement([0, 1]),
|
||||||
|
createTime: formatterCN.format(
|
||||||
|
faker.date.between({ from: '2022-01-01', to: '2025-01-01' }),
|
||||||
|
),
|
||||||
|
permissions: faker.helpers.arrayElements(menuIds),
|
||||||
|
remark: faker.lorem.sentence(),
|
||||||
|
};
|
||||||
|
|
||||||
|
dataList.push(dataItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData = generateMockDataList(100);
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
remark,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
status,
|
||||||
|
} = getQuery(event);
|
||||||
|
let listData = structuredClone(mockData);
|
||||||
|
if (name) {
|
||||||
|
listData = listData.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(String(name).toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
listData = listData.filter((item) =>
|
||||||
|
item.id.toLowerCase().includes(String(id).toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (remark) {
|
||||||
|
listData = listData.filter((item) =>
|
||||||
|
item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (startTime) {
|
||||||
|
listData = listData.filter((item) => item.createTime >= startTime);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
listData = listData.filter((item) => item.createTime <= endTime);
|
||||||
|
}
|
||||||
|
if (['0', '1'].includes(status as string)) {
|
||||||
|
listData = listData.filter((item) => item.status === Number(status));
|
||||||
|
}
|
||||||
|
return usePageResponseSuccess(page as string, pageSize as string, listData);
|
||||||
|
});
|
||||||
85
apps/backend-mock/api/system/user/list.ts
Normal file
85
apps/backend-mock/api/system/user/list.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { eventHandler, getQuery } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
const formatterCN = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateMockDataList(count: number) {
|
||||||
|
const dataList = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const dataItem: Record<string, any> = {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: faker.commerce.product(),
|
||||||
|
status: faker.helpers.arrayElement([0, 1]),
|
||||||
|
createTime: formatterCN.format(
|
||||||
|
faker.date.between({ from: '2022-01-01', to: '2025-01-01' }),
|
||||||
|
),
|
||||||
|
deptId: faker.string.uuid(),
|
||||||
|
remark: faker.lorem.sentence(),
|
||||||
|
};
|
||||||
|
|
||||||
|
dataList.push(dataItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData = generateMockDataList(100);
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
remark,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
deptId,
|
||||||
|
status,
|
||||||
|
} = getQuery(event);
|
||||||
|
let listData = structuredClone(mockData);
|
||||||
|
if (name) {
|
||||||
|
listData = listData.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(String(name).toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
listData = listData.filter((item) =>
|
||||||
|
item.id.toLowerCase().includes(String(id).toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (remark) {
|
||||||
|
listData = listData.filter((item) =>
|
||||||
|
item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (startTime) {
|
||||||
|
listData = listData.filter((item) => item.createTime >= startTime);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
listData = listData.filter((item) => item.createTime <= endTime);
|
||||||
|
}
|
||||||
|
if (['0', '1'].includes(status as string)) {
|
||||||
|
listData = listData.filter((item) => item.status === Number(status));
|
||||||
|
}
|
||||||
|
if (deptId) {
|
||||||
|
listData = listData.filter((item) => item.deptId === deptId);
|
||||||
|
}
|
||||||
|
return usePageResponseSuccess(page as string, pageSize as string, listData);
|
||||||
|
});
|
||||||
12
apps/backend-mock/api/timezone/getTimezone.ts
Normal file
12
apps/backend-mock/api/timezone/getTimezone.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
|
||||||
|
import { getTimezone } from '~/utils/timezone-utils';
|
||||||
|
|
||||||
|
export default eventHandler((event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
return useResponseSuccess(getTimezone());
|
||||||
|
});
|
||||||
11
apps/backend-mock/api/timezone/getTimezoneOptions.ts
Normal file
11
apps/backend-mock/api/timezone/getTimezoneOptions.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { TIME_ZONE_OPTIONS } from '~/utils/mock-data';
|
||||||
|
import { useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default eventHandler(() => {
|
||||||
|
const data = TIME_ZONE_OPTIONS.map((o) => ({
|
||||||
|
label: `${o.timezone} (GMT${o.offset >= 0 ? `+${o.offset}` : o.offset})`,
|
||||||
|
value: o.timezone,
|
||||||
|
}));
|
||||||
|
return useResponseSuccess(data);
|
||||||
|
});
|
||||||
26
apps/backend-mock/api/timezone/setTimezone.ts
Normal file
26
apps/backend-mock/api/timezone/setTimezone.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { eventHandler, readBody, setResponseStatus } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { TIME_ZONE_OPTIONS } from '~/utils/mock-data';
|
||||||
|
import {
|
||||||
|
unAuthorizedResponse,
|
||||||
|
useResponseError,
|
||||||
|
useResponseSuccess,
|
||||||
|
} from '~/utils/response';
|
||||||
|
import { setTimezone } from '~/utils/timezone-utils';
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
const body = await readBody<{ timezone?: unknown }>(event);
|
||||||
|
const timezone =
|
||||||
|
typeof body?.timezone === 'string' ? body.timezone : undefined;
|
||||||
|
const allowed = TIME_ZONE_OPTIONS.some((o) => o.timezone === timezone);
|
||||||
|
if (!timezone || !allowed) {
|
||||||
|
setResponseStatus(event, 400);
|
||||||
|
return useResponseError('Bad Request', 'Invalid timezone');
|
||||||
|
}
|
||||||
|
setTimezone(timezone);
|
||||||
|
return useResponseSuccess({});
|
||||||
|
});
|
||||||
14
apps/backend-mock/api/upload.ts
Normal file
14
apps/backend-mock/api/upload.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { eventHandler } from 'h3';
|
||||||
|
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||||
|
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default eventHandler((event) => {
|
||||||
|
const userinfo = verifyAccessToken(event);
|
||||||
|
if (!userinfo) {
|
||||||
|
return unAuthorizedResponse(event);
|
||||||
|
}
|
||||||
|
return useResponseSuccess({
|
||||||
|
url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
|
||||||
|
});
|
||||||
|
// return useResponseError("test")
|
||||||
|
});
|
||||||
9
apps/backend-mock/utils/timezone-utils.ts
Normal file
9
apps/backend-mock/utils/timezone-utils.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
let mockTimeZone: null | string = null;
|
||||||
|
|
||||||
|
export const setTimezone = (timeZone: string) => {
|
||||||
|
mockTimeZone = timeZone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimezone = () => {
|
||||||
|
return mockTimeZone;
|
||||||
|
};
|
||||||
@@ -21,3 +21,4 @@ VITE_ARCHIVER=true
|
|||||||
|
|
||||||
# WebSocket 连接地址
|
# WebSocket 连接地址
|
||||||
VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws
|
VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws
|
||||||
|
# VITE_WS_URL=wss://xk.ws.nailaoyun.cn/ws
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
||||||
/>
|
/>
|
||||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||||
<title><%= VITE_APP_TITLE %></title>
|
<title>%VITE_APP_TITLE%</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<script>
|
<script>
|
||||||
// 生产环境下注入百度统计
|
// 生产环境下注入百度统计
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@vben/web-antd",
|
"name": "@vben/web-antd",
|
||||||
"version": "5.5.1",
|
"version": "5.7.0",
|
||||||
"homepage": "https://vben.pro",
|
"homepage": "https://vben.pro",
|
||||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@vben/access": "workspace:*",
|
"@vben/access": "workspace:*",
|
||||||
"@vben/common-ui": "workspace:*",
|
"@vben/common-ui": "workspace:*",
|
||||||
"@vben/constants": "workspace:*",
|
"@vben/constants": "workspace:*",
|
||||||
@@ -41,15 +42,23 @@
|
|||||||
"@vben/styles": "workspace:*",
|
"@vben/styles": "workspace:*",
|
||||||
"@vben/types": "workspace:*",
|
"@vben/types": "workspace:*",
|
||||||
"@vben/utils": "workspace:*",
|
"@vben/utils": "workspace:*",
|
||||||
|
"@vueup/vue-quill": "^1.2.0",
|
||||||
"@vueuse/core": "catalog:",
|
"@vueuse/core": "catalog:",
|
||||||
"ant-design-vue": "catalog:",
|
"ant-design-vue": "catalog:",
|
||||||
|
"axios": "^1.10.0",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"js-base64": "^3.7.7",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"lucide-vue-next": "^0.487.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
"md-editor-v3": "^5.4.5",
|
||||||
"pinia": "catalog:",
|
"pinia": "catalog:",
|
||||||
"sortablejs": "catalog:",
|
"sortablejs": "catalog:",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-router": "catalog:",
|
"vue-router": "catalog:",
|
||||||
|
"xgplayer": "^3.0.24",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '@vben/tailwind-config/postcss';
|
|
||||||
@@ -3,55 +3,616 @@
|
|||||||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BaseFormComponentType } from '@vben/common-ui';
|
/* eslint-disable vue/one-component-per-file */
|
||||||
|
|
||||||
import type { Component, SetupContext } from 'vue';
|
import type {
|
||||||
import { h } from 'vue';
|
AutoCompleteProps,
|
||||||
|
ButtonProps,
|
||||||
|
CascaderProps,
|
||||||
|
CheckboxGroupProps,
|
||||||
|
CheckboxProps,
|
||||||
|
DatePickerProps,
|
||||||
|
DividerProps,
|
||||||
|
InputNumberProps,
|
||||||
|
InputProps,
|
||||||
|
MentionsProps,
|
||||||
|
RadioGroupProps,
|
||||||
|
RadioProps,
|
||||||
|
RateProps,
|
||||||
|
SelectProps,
|
||||||
|
SpaceProps,
|
||||||
|
SwitchProps,
|
||||||
|
TextAreaProps,
|
||||||
|
TimePickerProps,
|
||||||
|
TreeSelectProps,
|
||||||
|
UploadChangeParam,
|
||||||
|
UploadFile,
|
||||||
|
UploadProps,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
import type { RangePickerProps } from 'ant-design-vue/es/date-picker';
|
||||||
|
|
||||||
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
|
import type { Component, Ref } from 'vue';
|
||||||
import { $t } from '@vben/locales';
|
|
||||||
|
import type {
|
||||||
|
ApiComponentSharedProps,
|
||||||
|
BaseFormComponentType,
|
||||||
|
IconPickerProps,
|
||||||
|
} from '@vben/common-ui';
|
||||||
|
import type { Sortable } from '@vben/hooks';
|
||||||
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AutoComplete,
|
computed,
|
||||||
Button,
|
defineAsyncComponent,
|
||||||
Cascader,
|
defineComponent,
|
||||||
Checkbox,
|
h,
|
||||||
CheckboxGroup,
|
nextTick,
|
||||||
DatePicker,
|
onMounted,
|
||||||
Divider,
|
onUnmounted,
|
||||||
Input,
|
ref,
|
||||||
InputNumber,
|
render,
|
||||||
InputPassword,
|
unref,
|
||||||
Mentions,
|
watch,
|
||||||
notification,
|
} from 'vue';
|
||||||
Radio,
|
|
||||||
RadioGroup,
|
import {
|
||||||
RangePicker,
|
ApiComponent,
|
||||||
Rate,
|
globalShareState,
|
||||||
Select,
|
IconPicker,
|
||||||
Space,
|
VCropper,
|
||||||
Switch,
|
} from '@vben/common-ui';
|
||||||
Textarea,
|
import { useSortable } from '@vben/hooks';
|
||||||
TimePicker,
|
import { IconifyIcon } from '@vben/icons';
|
||||||
TreeSelect,
|
import { $t } from '@vben/locales';
|
||||||
Upload,
|
import { isEmpty } from '@vben/utils';
|
||||||
} from 'ant-design-vue';
|
|
||||||
|
import { message, Modal, notification } from 'ant-design-vue';
|
||||||
|
|
||||||
import { registerComponent } from '#/components/form/component-map';
|
import { registerComponent } from '#/components/form/component-map';
|
||||||
|
|
||||||
|
type AdapterUploadProps = UploadProps & {
|
||||||
|
aspectRatio?: string;
|
||||||
|
crop?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
|
handleChange?: (event: UploadChangeParam) => void;
|
||||||
|
maxSize?: number;
|
||||||
|
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||||
|
onHandleChange?: (event: UploadChangeParam) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 所有 antd 组件统一用 defineAsyncComponent 异步加载
|
||||||
|
// ⚠️ 不要再从 'ant-design-vue' 同步 import 这些组件!
|
||||||
|
// 否则会和下面的 const 声明冲突,导致
|
||||||
|
// "Identifier 'Xxx' has already been declared"
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const AutoComplete = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/auto-complete'),
|
||||||
|
);
|
||||||
|
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
|
||||||
|
const Checkbox = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/checkbox'),
|
||||||
|
);
|
||||||
|
const CheckboxGroup = defineAsyncComponent(() =>
|
||||||
|
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
|
||||||
|
);
|
||||||
|
const DatePicker = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/date-picker'),
|
||||||
|
);
|
||||||
|
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
|
||||||
|
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
|
||||||
|
const InputNumber = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/input-number'),
|
||||||
|
);
|
||||||
|
const InputPassword = defineAsyncComponent(() =>
|
||||||
|
import('ant-design-vue/es/input').then((res) => res.InputPassword),
|
||||||
|
);
|
||||||
|
const Mentions = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/mentions'),
|
||||||
|
);
|
||||||
|
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
|
||||||
|
const RadioGroup = defineAsyncComponent(() =>
|
||||||
|
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
|
||||||
|
);
|
||||||
|
const RangePicker = defineAsyncComponent(() =>
|
||||||
|
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
|
||||||
|
);
|
||||||
|
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
|
||||||
|
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
|
||||||
|
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
|
||||||
|
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
|
||||||
|
const Textarea = defineAsyncComponent(() =>
|
||||||
|
import('ant-design-vue/es/input').then((res) => res.Textarea),
|
||||||
|
);
|
||||||
|
const TimePicker = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/time-picker'),
|
||||||
|
);
|
||||||
|
const TreeSelect = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/tree-select'),
|
||||||
|
);
|
||||||
|
const Cascader = defineAsyncComponent(
|
||||||
|
() => import('ant-design-vue/es/cascader'),
|
||||||
|
);
|
||||||
|
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
|
||||||
|
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
|
||||||
|
const PreviewGroup = defineAsyncComponent(() =>
|
||||||
|
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// withDefaultPlaceholder:给组件注入 i18n placeholder + ref 透传
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = <T extends Component>(
|
||||||
component: T,
|
component: Component,
|
||||||
type: 'input' | 'select',
|
type: 'input' | 'select',
|
||||||
|
componentProps: Recordable<any> = {},
|
||||||
) => {
|
) => {
|
||||||
return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
|
return defineComponent({
|
||||||
const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
|
name: (component as any).name,
|
||||||
return h(component, { ...props, ...attrs, placeholder }, slots);
|
inheritAttrs: false,
|
||||||
|
setup: (props: any, { attrs, expose, slots }) => {
|
||||||
|
const placeholder =
|
||||||
|
props?.placeholder ||
|
||||||
|
attrs?.placeholder ||
|
||||||
|
$t(`ui.placeholder.${type}`);
|
||||||
|
// 透传组件暴露的方法
|
||||||
|
const innerRef = ref();
|
||||||
|
expose(
|
||||||
|
new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, key) => innerRef.value?.[key],
|
||||||
|
has: (_target, key) => key in (innerRef.value || {}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
component,
|
||||||
|
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
|
||||||
|
slots,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
|
'bmp',
|
||||||
|
'gif',
|
||||||
|
'jpeg',
|
||||||
|
'jpg',
|
||||||
|
'png',
|
||||||
|
'svg',
|
||||||
|
'webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为图片文件
|
||||||
|
*/
|
||||||
|
function isImageFile(file: UploadFile): boolean {
|
||||||
|
if (file.url) {
|
||||||
|
try {
|
||||||
|
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||||
|
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
|
} catch {
|
||||||
|
const ext = file.url?.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!file.type) {
|
||||||
|
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
|
}
|
||||||
|
return file.type.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建默认的上传按钮插槽
|
||||||
|
*/
|
||||||
|
function createDefaultUploadSlots(listType: string, placeholder: string) {
|
||||||
|
if (listType === 'picture-card') {
|
||||||
|
return { default: () => placeholder };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{
|
||||||
|
icon: h(IconifyIcon, {
|
||||||
|
icon: 'ant-design:upload-outlined',
|
||||||
|
class: 'mb-1 size-4',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
() => placeholder,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件的 Base64
|
||||||
|
*/
|
||||||
|
function getBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||||
|
reader.addEventListener('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览图片
|
||||||
|
*/
|
||||||
|
async function previewImage(
|
||||||
|
file: UploadFile,
|
||||||
|
visible: Ref<boolean>,
|
||||||
|
fileList: Ref<UploadProps['fileList']>,
|
||||||
|
) {
|
||||||
|
// 非图片文件直接打开链接
|
||||||
|
if (!isImageFile(file)) {
|
||||||
|
const url = file.url || file.preview;
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
} else {
|
||||||
|
message.error($t('ui.formRules.previewWarning'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
||||||
|
Image,
|
||||||
|
PreviewGroup,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 过滤图片文件并生成预览
|
||||||
|
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
|
||||||
|
|
||||||
|
for (const imgFile of imageFiles) {
|
||||||
|
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
|
||||||
|
imgFile.preview = await getBase64(imgFile.originFileObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
let isUnmounted = false;
|
||||||
|
|
||||||
|
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
|
||||||
|
|
||||||
|
const PreviewWrapper = {
|
||||||
|
setup() {
|
||||||
|
return () => {
|
||||||
|
if (isUnmounted) return null;
|
||||||
|
return h(
|
||||||
|
PreviewGroupComponent,
|
||||||
|
{
|
||||||
|
class: 'hidden',
|
||||||
|
preview: {
|
||||||
|
visible: visible.value,
|
||||||
|
current: currentIndex,
|
||||||
|
onVisibleChange: (value: boolean) => {
|
||||||
|
visible.value = value;
|
||||||
|
if (!value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isUnmounted && container) {
|
||||||
|
isUnmounted = true;
|
||||||
|
render(null, container);
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
imageFiles.map((imgFile) =>
|
||||||
|
h(ImageComponent, {
|
||||||
|
key: imgFile.uid,
|
||||||
|
src: imgFile.url || imgFile.preview,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(h(PreviewWrapper), container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片裁剪操作
|
||||||
|
*/
|
||||||
|
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||||
|
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
|
||||||
|
let isUnmounted = false;
|
||||||
|
let objectUrl: null | string = null;
|
||||||
|
|
||||||
|
const open = ref(true);
|
||||||
|
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
open.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isUnmounted && container) {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
isUnmounted = true;
|
||||||
|
render(null, container);
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CropperWrapper = {
|
||||||
|
setup() {
|
||||||
|
return () => {
|
||||||
|
if (isUnmounted) return null;
|
||||||
|
if (!objectUrl) {
|
||||||
|
objectUrl = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
Modal,
|
||||||
|
{
|
||||||
|
open: open.value,
|
||||||
|
title: h('div', {}, [
|
||||||
|
$t('ui.crop.title'),
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||||
|
},
|
||||||
|
$t('ui.crop.titleTip', [aspectRatio]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
centered: true,
|
||||||
|
width: 548,
|
||||||
|
zIndex: 9999,
|
||||||
|
keyboard: false,
|
||||||
|
maskClosable: false,
|
||||||
|
closable: false,
|
||||||
|
cancelText: $t('common.cancel'),
|
||||||
|
okText: $t('ui.crop.confirm'),
|
||||||
|
destroyOnClose: true,
|
||||||
|
onOk: async () => {
|
||||||
|
const cropper = cropperRef.value;
|
||||||
|
if (!cropper) {
|
||||||
|
reject(new Error('Cropper not found'));
|
||||||
|
closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dataUrl = await cropper.getCropImage();
|
||||||
|
if (dataUrl) {
|
||||||
|
resolve(dataUrl);
|
||||||
|
} else {
|
||||||
|
reject(new Error($t('ui.crop.errorTip')));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reject(new Error($t('ui.crop.errorTip')));
|
||||||
|
} finally {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
resolve('');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
h(VCropper, {
|
||||||
|
ref: (refValue: any) => (cropperRef.value = refValue),
|
||||||
|
img: objectUrl as string,
|
||||||
|
aspectRatio,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(h(CropperWrapper), container);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带预览功能的上传组件
|
||||||
|
*/
|
||||||
|
const withPreviewUpload = () => {
|
||||||
|
return defineComponent({
|
||||||
|
name: 'Upload',
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(
|
||||||
|
props: any,
|
||||||
|
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
||||||
|
) {
|
||||||
|
const previewVisible = ref<boolean>(false);
|
||||||
|
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
|
||||||
|
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
||||||
|
const fileList = ref<UploadProps['fileList']>(
|
||||||
|
attrs?.fileList || attrs?.['file-list'] || [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
|
||||||
|
const aspectRatio = computed(
|
||||||
|
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBeforeUpload = async (
|
||||||
|
file: UploadFile,
|
||||||
|
originFileList: Array<File>,
|
||||||
|
) => {
|
||||||
|
// 文件大小限制
|
||||||
|
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
||||||
|
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
||||||
|
file.status = 'removed';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片裁剪处理
|
||||||
|
if (
|
||||||
|
attrs.crop &&
|
||||||
|
!attrs.multiple &&
|
||||||
|
originFileList[0] &&
|
||||||
|
isImageFile(file)
|
||||||
|
) {
|
||||||
|
file.status = 'removed';
|
||||||
|
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error($t('ui.crop.errorTip'));
|
||||||
|
}
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs.beforeUpload?.(file) ?? true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (event: UploadChangeParam) => {
|
||||||
|
try {
|
||||||
|
attrs.handleChange?.(event);
|
||||||
|
attrs.onHandleChange?.(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
fileList.value = event.fileList.filter(
|
||||||
|
(file) => file.status !== 'removed',
|
||||||
|
);
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
event.fileList?.length ? fileList.value : undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async (file: UploadFile) => {
|
||||||
|
previewVisible.value = true;
|
||||||
|
await previewImage(file, previewVisible, fileList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderUploadButton = () => {
|
||||||
|
if (attrs.disabled) return null;
|
||||||
|
return isEmpty(slots)
|
||||||
|
? createDefaultUploadSlots(listType, placeholder)
|
||||||
|
: slots;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖拽排序
|
||||||
|
const draggable = computed(
|
||||||
|
() => (attrs.draggable ?? false) && !attrs.disabled,
|
||||||
|
);
|
||||||
|
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
const sortableInstance = ref<null | Sortable>(null);
|
||||||
|
|
||||||
|
const styleId = `upload-drag-style-${uploadId}`;
|
||||||
|
|
||||||
|
function injectDragStyle() {
|
||||||
|
if (!document.querySelector(`[id="${styleId}"]`)) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = styleId;
|
||||||
|
style.textContent = `
|
||||||
|
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
|
||||||
|
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||||
|
`;
|
||||||
|
document.head?.append(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDragStyle() {
|
||||||
|
document.querySelector(`[id="${styleId}"]`)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSortable(retryCount = 0) {
|
||||||
|
if (!draggable.value) return;
|
||||||
|
|
||||||
|
injectDragStyle();
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const container = document.querySelector(
|
||||||
|
`[data-upload-id="${uploadId}"] .ant-upload-list`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
if (retryCount < 5) {
|
||||||
|
setTimeout(() => initSortable(retryCount + 1), 200);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { initializeSortable } = useSortable(container, {
|
||||||
|
animation: 300,
|
||||||
|
delay: 400,
|
||||||
|
delayOnTouchOnly: true,
|
||||||
|
filter:
|
||||||
|
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
|
||||||
|
onEnd: (evt: { oldIndex?: number; newIndex?: number }) => {
|
||||||
|
const { oldIndex, newIndex } = evt;
|
||||||
|
if (
|
||||||
|
oldIndex === undefined ||
|
||||||
|
newIndex === undefined ||
|
||||||
|
oldIndex === newIndex
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = [...(fileList.value || [])];
|
||||||
|
const [movedItem] = list.splice(oldIndex, 1);
|
||||||
|
if (movedItem) {
|
||||||
|
list.splice(newIndex, 0, movedItem);
|
||||||
|
fileList.value = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs.onDragSort?.(oldIndex, newIndex);
|
||||||
|
emit('update:modelValue', fileList.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sortableInstance.value = await initializeSortable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听表单值变化
|
||||||
|
watch(
|
||||||
|
() => attrs.modelValue,
|
||||||
|
(res) => {
|
||||||
|
fileList.value = res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(initSortable);
|
||||||
|
onUnmounted(() => {
|
||||||
|
sortableInstance.value?.destroy();
|
||||||
|
removeDragStyle();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||||
|
h(
|
||||||
|
Upload,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
...attrs,
|
||||||
|
fileList: fileList.value,
|
||||||
|
beforeUpload: handleBeforeUpload,
|
||||||
|
onChange: handleChange,
|
||||||
|
onPreview: handlePreview,
|
||||||
|
},
|
||||||
|
renderUploadButton() as any,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 表单组件在这里加
|
// 表单组件在这里加
|
||||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||||
export type ComponentType =
|
export type ComponentType =
|
||||||
|
| 'ApiCascader'
|
||||||
| 'ApiSelect'
|
| 'ApiSelect'
|
||||||
| 'ApiTreeSelect'
|
| 'ApiTreeSelect'
|
||||||
| 'AutoComplete'
|
| 'AutoComplete'
|
||||||
@@ -61,7 +622,8 @@ export type ComponentType =
|
|||||||
| 'DatePicker'
|
| 'DatePicker'
|
||||||
| 'DefaultButton'
|
| 'DefaultButton'
|
||||||
| 'Divider'
|
| 'Divider'
|
||||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
/** OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型) */
|
||||||
|
| 'GalleryPickLink'
|
||||||
| 'IconPicker'
|
| 'IconPicker'
|
||||||
| 'Input'
|
| 'Input'
|
||||||
| 'InputNumber'
|
| 'InputNumber'
|
||||||
@@ -82,68 +644,92 @@ export type ComponentType =
|
|||||||
| 'UploadImage'
|
| 'UploadImage'
|
||||||
| 'UploadImageSortable'
|
| 'UploadImageSortable'
|
||||||
| 'UploadOssFile'
|
| 'UploadOssFile'
|
||||||
|
| 'UploadDraggerPaste'
|
||||||
| BaseFormComponentType;
|
| BaseFormComponentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示
|
||||||
|
*/
|
||||||
|
export interface ComponentPropsMap {
|
||||||
|
ApiCascader: ApiComponentSharedProps & CascaderProps;
|
||||||
|
ApiSelect: ApiComponentSharedProps & SelectProps;
|
||||||
|
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
|
||||||
|
AutoComplete: AutoCompleteProps;
|
||||||
|
Cascader: CascaderProps;
|
||||||
|
Checkbox: CheckboxProps;
|
||||||
|
CheckboxGroup: CheckboxGroupProps;
|
||||||
|
DatePicker: DatePickerProps;
|
||||||
|
DefaultButton: ButtonProps;
|
||||||
|
Divider: DividerProps;
|
||||||
|
IconPicker: IconPickerProps;
|
||||||
|
Input: InputProps;
|
||||||
|
InputNumber: InputNumberProps;
|
||||||
|
InputPassword: InputProps;
|
||||||
|
Mentions: MentionsProps;
|
||||||
|
PrimaryButton: ButtonProps;
|
||||||
|
Radio: RadioProps;
|
||||||
|
RadioGroup: RadioGroupProps;
|
||||||
|
RangePicker: RangePickerProps;
|
||||||
|
Rate: RateProps;
|
||||||
|
Select: SelectProps;
|
||||||
|
Space: SpaceProps;
|
||||||
|
Switch: SwitchProps;
|
||||||
|
Textarea: TextAreaProps;
|
||||||
|
TimePicker: TimePickerProps;
|
||||||
|
TreeSelect: TreeSelectProps;
|
||||||
|
Upload: AdapterUploadProps;
|
||||||
|
}
|
||||||
|
|
||||||
async function initComponentAdapter() {
|
async function initComponentAdapter() {
|
||||||
const components: Partial<Record<ComponentType, Component>> = {
|
const components: Partial<Record<ComponentType, Component>> = {
|
||||||
// 如果你的组件体积比较大,可以使用异步加载
|
// 如果你的组件体积比较大,可以使用异步加载
|
||||||
// Button: () =>
|
// Button: () =>
|
||||||
// import('xxx').then((res) => res.Button),
|
// import('xxx').then((res) => res.Button),
|
||||||
ApiSelect: (props, { attrs, slots }) => {
|
|
||||||
return h(
|
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
ApiComponent,
|
component: Cascader,
|
||||||
{
|
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||||
placeholder: $t('ui.placeholder.select'),
|
loadingSlot: 'suffixIcon',
|
||||||
...props,
|
modelPropName: 'value',
|
||||||
...attrs,
|
visibleEvent: 'onVisibleChange',
|
||||||
component: Select,
|
}),
|
||||||
loadingSlot: 'suffixIcon',
|
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
visibleEvent: 'onDropdownVisibleChange',
|
component: Select,
|
||||||
modelPropName: 'value',
|
loadingSlot: 'suffixIcon',
|
||||||
},
|
modelPropName: 'value',
|
||||||
slots,
|
visibleEvent: 'onVisibleChange',
|
||||||
);
|
}),
|
||||||
},
|
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
ApiTreeSelect: (props, { attrs, slots }) => {
|
component: TreeSelect,
|
||||||
return h(
|
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||||
ApiComponent,
|
loadingSlot: 'suffixIcon',
|
||||||
{
|
modelPropName: 'value',
|
||||||
placeholder: $t('ui.placeholder.select'),
|
optionsPropName: 'treeData',
|
||||||
...props,
|
visibleEvent: 'onVisibleChange',
|
||||||
...attrs,
|
}),
|
||||||
component: TreeSelect,
|
|
||||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
|
||||||
loadingSlot: 'suffixIcon',
|
|
||||||
modelPropName: 'value',
|
|
||||||
optionsPropName: 'treeData',
|
|
||||||
visibleEvent: 'onVisibleChange',
|
|
||||||
},
|
|
||||||
slots,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
Cascader,
|
Cascader,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CheckboxGroup,
|
CheckboxGroup,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
// 自定义默认按钮
|
// 自定义默认按钮
|
||||||
DefaultButton: (props, { attrs, slots }) => {
|
DefaultButton: (props: any, { attrs, slots }: any) => {
|
||||||
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||||
},
|
},
|
||||||
Divider,
|
Divider,
|
||||||
IconPicker: (props, { attrs, slots }) => {
|
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||||
return h(
|
iconSlot: 'addonAfter',
|
||||||
IconPicker,
|
inputComponent: Input,
|
||||||
{ iconSlot: 'addonAfter', inputComponent: Input, ...props, ...attrs },
|
modelValueProp: 'value',
|
||||||
slots,
|
}),
|
||||||
);
|
|
||||||
},
|
|
||||||
Input: withDefaultPlaceholder(Input, 'input'),
|
Input: withDefaultPlaceholder(Input, 'input'),
|
||||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
|
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||||
|
style: { width: '100%' },
|
||||||
|
}),
|
||||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||||
// 自定义主要按钮
|
// 自定义主要按钮
|
||||||
PrimaryButton: (props, { attrs, slots }) => {
|
PrimaryButton: (props: any, { attrs, slots }: any) => {
|
||||||
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
||||||
},
|
},
|
||||||
Radio,
|
Radio,
|
||||||
@@ -156,10 +742,10 @@ async function initComponentAdapter() {
|
|||||||
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
||||||
TimePicker,
|
TimePicker,
|
||||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||||
Upload,
|
Upload: withPreviewUpload(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 自动注册自定义组件
|
// 自动注册 components/form 与 views/**/components/form 下的业务表单组件(如 Editor、Avatar)
|
||||||
registerComponent(components);
|
registerComponent(components);
|
||||||
// 将组件注册到全局共享状态中
|
// 将组件注册到全局共享状态中
|
||||||
globalShareState.setComponents(components);
|
globalShareState.setComponents(components);
|
||||||
|
|||||||
@@ -1,49 +1,84 @@
|
|||||||
import type {
|
import type {
|
||||||
|
VbenFormProps as FormProps,
|
||||||
VbenFormSchema as FormSchema,
|
VbenFormSchema as FormSchema,
|
||||||
VbenFormProps,
|
FormValues,
|
||||||
} from '@vben/common-ui';
|
} from '@vben/common-ui';
|
||||||
|
|
||||||
import type { ComponentType } from './component';
|
import type { ComponentPropsMap, ComponentType } from './component';
|
||||||
|
|
||||||
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
setupVbenForm<ComponentType>({
|
async function initSetupVbenForm() {
|
||||||
config: {
|
setupVbenForm<ComponentType>({
|
||||||
// ant design vue组件库默认都是 v-model:value
|
config: {
|
||||||
baseModelPropName: 'value',
|
// ant design vue组件库默认都是 v-model:value
|
||||||
|
baseModelPropName: 'value',
|
||||||
|
|
||||||
// 一些组件是 v-model:checked 或者 v-model:fileList
|
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||||
modelPropNameMap: {
|
modelPropNameMap: {
|
||||||
Checkbox: 'checked',
|
Checkbox: 'checked',
|
||||||
Radio: 'checked',
|
Radio: 'checked',
|
||||||
Switch: 'checked',
|
Switch: 'checked',
|
||||||
Upload: 'fileList',
|
Upload: 'fileList',
|
||||||
UploadImage: 'modelValue',
|
UploadImage: 'modelValue',
|
||||||
UploadOssFile: 'modelValue',
|
UploadOssFile: 'modelValue',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
rules: {
|
||||||
defineRules: {
|
// 输入项目必填国际化适配
|
||||||
// 输入项目必填国际化适配
|
required: (value, _params, ctx) => {
|
||||||
required: (value, _params, ctx) => {
|
if (value === undefined || value === null || value.length === 0) {
|
||||||
if (value === undefined || value === null || value.length === 0) {
|
return $t('ui.formRules.required', [ctx.label]);
|
||||||
return $t('ui.formRules.required', [ctx.label]);
|
}
|
||||||
}
|
return true;
|
||||||
return true;
|
},
|
||||||
|
// 选择项目必填国际化适配
|
||||||
|
selectRequired: (value, _params, ctx) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return $t('ui.formRules.selectRequired', [ctx.label]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 大陆手机号:必填 + 11 位且号段 13-19(与后端 UtilsService::checkPhone 一致)
|
||||||
|
* 表单字段 rules: 'mobile' 即可,无需再叠 required
|
||||||
|
*/
|
||||||
|
mobile: (value, _params, ctx) => {
|
||||||
|
const phone = typeof value === 'string' ? value.trim() : value;
|
||||||
|
if (phone === undefined || phone === null || phone === '') {
|
||||||
|
return $t('ui.formRules.required', [ctx.label]);
|
||||||
|
}
|
||||||
|
if (!/^1[3456789]\d{9}$/.test(String(phone))) {
|
||||||
|
return $t('ui.formRules.mobile', [ctx.label]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// 选择项目必填国际化适配
|
});
|
||||||
selectRequired: (value, _params, ctx) => {
|
}
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return $t('ui.formRules.selectRequired', [ctx.label]);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const useVbenForm = useForm<ComponentType>;
|
function useVbenForm<
|
||||||
|
TFormValues extends FormValues = FormValues,
|
||||||
|
TSubmitValues extends FormValues = TFormValues,
|
||||||
|
>(
|
||||||
|
options: FormProps<
|
||||||
|
ComponentType,
|
||||||
|
ComponentPropsMap,
|
||||||
|
TFormValues,
|
||||||
|
TSubmitValues
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { useVbenForm, z };
|
export { initSetupVbenForm, useVbenForm, z };
|
||||||
|
|
||||||
export type VbenFormSchema = FormSchema<ComponentType>;
|
export type VbenFormSchema<TValues extends FormValues = FormValues> =
|
||||||
export type { VbenFormProps };
|
FormSchema<ComponentType, ComponentPropsMap, TValues>;
|
||||||
|
export type VbenFormProps<
|
||||||
|
TFormValues extends FormValues = FormValues,
|
||||||
|
TSubmitValues extends FormValues = TFormValues,
|
||||||
|
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
import type { FormValues } from '@vben/common-ui';
|
||||||
|
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||||
|
|
||||||
|
import type { ComponentPropsMap, ComponentType } from './component';
|
||||||
|
|
||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
import {
|
||||||
|
setupVbenVxeTable,
|
||||||
|
useVbenVxeGrid as useGrid,
|
||||||
|
} from '@vben/plugins/vxe-table';
|
||||||
|
|
||||||
import { Button, Image } from 'ant-design-vue';
|
import { Button, Image } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -33,14 +41,15 @@ setupVbenVxeTable({
|
|||||||
round: true,
|
round: true,
|
||||||
showOverflow: true,
|
showOverflow: true,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
},
|
} as VxeTableGridOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 表格配置项可以用 cellRender: { name: 'CellImage' },
|
// 表格配置项可以用 cellRender: { name: 'CellImage' },
|
||||||
vxeUI.renderer.add('CellImage', {
|
vxeUI.renderer.add('CellImage', {
|
||||||
renderTableDefault(_renderOpts, params) {
|
renderTableDefault(renderOpts, params) {
|
||||||
|
const { props } = renderOpts;
|
||||||
const { column, row } = params;
|
const { column, row } = params;
|
||||||
return h(Image, { src: row[column.field] });
|
return h(Image, { src: row[column.field], ...props });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,6 +71,23 @@ setupVbenVxeTable({
|
|||||||
useVbenForm,
|
useVbenForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
export { useVbenVxeGrid };
|
export const useVbenVxeGrid = <
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFormValues extends FormValues = FormValues,
|
||||||
|
TSubmitValues extends FormValues = TFormValues,
|
||||||
|
>(
|
||||||
|
...rest: Parameters<
|
||||||
|
typeof useGrid<
|
||||||
|
T,
|
||||||
|
ComponentType,
|
||||||
|
ComponentPropsMap,
|
||||||
|
TFormValues,
|
||||||
|
TSubmitValues
|
||||||
|
>
|
||||||
|
>
|
||||||
|
) =>
|
||||||
|
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
|
||||||
|
...rest,
|
||||||
|
);
|
||||||
|
|
||||||
export type * from '@vben/plugins/vxe-table';
|
export type * from '@vben/plugins/vxe-table';
|
||||||
|
|||||||
@@ -54,12 +54,15 @@ export namespace AuthApi {
|
|||||||
* 登录
|
* 登录
|
||||||
*/
|
*/
|
||||||
export async function loginApi(data: AuthApi.LoginParams) {
|
export async function loginApi(data: AuthApi.LoginParams) {
|
||||||
|
// PC 管理端固定 web,供单机登录按端区分会话
|
||||||
|
const clientType = 'web';
|
||||||
if (data.account) {
|
if (data.account) {
|
||||||
return requestClient.post<AuthApi.LoginResult>('login', {
|
return requestClient.post<AuthApi.LoginResult>('login', {
|
||||||
account: data.account,
|
account: data.account,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
code: data.code,
|
code: data.code,
|
||||||
admin_id: data.admin_id,
|
admin_id: data.admin_id,
|
||||||
|
client_type: clientType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ export async function loginApi(data: AuthApi.LoginParams) {
|
|||||||
login_method: data.login_method || 'phone',
|
login_method: data.login_method || 'phone',
|
||||||
password: data.password,
|
password: data.password,
|
||||||
code: data.code,
|
code: data.code,
|
||||||
|
client_type: clientType,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.login_method === 'phone') {
|
if (data.login_method === 'phone') {
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* 该文件可自行根据业务逻辑进行调整
|
* 该文件可自行根据业务逻辑进行调整
|
||||||
*/
|
*/
|
||||||
import type { HttpResponse } from '@vben/request';
|
import type { RequestClientOptions } from '@vben/request';
|
||||||
|
|
||||||
import { useAppConfig } from '@vben/hooks';
|
import { useAppConfig } from '@vben/hooks';
|
||||||
import { preferences } from '@vben/preferences';
|
import { preferences } from '@vben/preferences';
|
||||||
import {
|
import {
|
||||||
authenticateResponseInterceptor,
|
authenticateResponseInterceptor,
|
||||||
|
defaultResponseInterceptor,
|
||||||
errorMessageResponseInterceptor,
|
errorMessageResponseInterceptor,
|
||||||
RequestClient,
|
RequestClient,
|
||||||
} from '@vben/request';
|
} from '@vben/request';
|
||||||
import { useAccessStore } from '@vben/stores';
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
// eslint-disable-next-line n/no-extraneous-import
|
// eslint-disable-next-line n/no-extraneous-import
|
||||||
import { Base64 } from 'js-base64';
|
import { Base64 } from 'js-base64';
|
||||||
|
|
||||||
@@ -29,14 +30,17 @@ import {downloadByData} from "#/util/tool";
|
|||||||
|
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
|
||||||
function createRequestClient(baseURL: string) {
|
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||||
const client = new RequestClient({
|
const client = new RequestClient({
|
||||||
|
...options,
|
||||||
baseURL,
|
baseURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重新认证逻辑
|
* 重新认证逻辑
|
||||||
*/
|
*/
|
||||||
|
/** 防重复:并发 401 只弹一次重新登录提示 */
|
||||||
|
let authModalShowing = false;
|
||||||
async function doReAuthenticate() {
|
async function doReAuthenticate() {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -47,6 +51,21 @@ function createRequestClient(baseURL: string) {
|
|||||||
) {
|
) {
|
||||||
accessStore.setLoginExpired(true);
|
accessStore.setLoginExpired(true);
|
||||||
} else {
|
} else {
|
||||||
|
// 被踢/登录失效时用模态框提示,确认后再退出
|
||||||
|
if (!authModalShowing) {
|
||||||
|
authModalShowing = true;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
Modal.warning({
|
||||||
|
title: '登录失效',
|
||||||
|
content: '账号需重新登录',
|
||||||
|
okText: '重新登录',
|
||||||
|
centered: true,
|
||||||
|
onOk: () => resolve(),
|
||||||
|
onCancel: () => resolve(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
authModalShowing = false;
|
||||||
|
}
|
||||||
await authStore.logout(true);
|
await authStore.logout(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +176,9 @@ function createRequestClient(baseURL: string) {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const requestClient = createRequestClient(apiURL);
|
export const requestClient = createRequestClient(apiURL, {
|
||||||
|
responseReturn: 'result',
|
||||||
|
});
|
||||||
|
|
||||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { App, ConfigProvider, theme } from 'ant-design-vue';
|
|||||||
|
|
||||||
import { antdLocale } from '#/locales';
|
import { antdLocale } from '#/locales';
|
||||||
import { GlobalContextMenu } from '#/components/context-menu';
|
import { GlobalContextMenu } from '#/components/context-menu';
|
||||||
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
|
||||||
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
|
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
|
||||||
import { useChatStore } from '#/views/business/chat/stores/chat';
|
import { useChatStore } from '#/views/business/chat/stores/chat';
|
||||||
import { useUserStore } from '#/views/business/chat/stores/user';
|
import { useUserStore } from '#/views/business/chat/stores/user';
|
||||||
@@ -76,13 +75,6 @@ watch(userInfoLoaded, (loaded) => {
|
|||||||
initWebsocket();
|
initWebsocket();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 诊所医生账号显示传方悬浮窗(有 doctor_id 且绑定门店) */
|
|
||||||
const showDoctorTransferFloat = computed(() => {
|
|
||||||
const info = vbenUserStore.userInfo as Record<string, any> | null;
|
|
||||||
if (!info) return false;
|
|
||||||
return !!(info.doctor_id && info.store_id);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -90,7 +82,6 @@ const showDoctorTransferFloat = computed(() => {
|
|||||||
<App>
|
<App>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<GlobalContextMenu />
|
<GlobalContextMenu />
|
||||||
<DoctorTransferFloat v-if="showDoctorTransferFloat" />
|
|
||||||
</App>
|
</App>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp, watchEffect } from 'vue';
|
import { createApp, watchEffect } from 'vue';
|
||||||
|
|
||||||
import { registerAccessDirective } from '@vben/access';
|
import { registerAccessDirective } from '@vben/access';
|
||||||
|
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||||
import { preferences } from '@vben/preferences';
|
import { preferences } from '@vben/preferences';
|
||||||
import { initStores } from '@vben/stores';
|
import { initStores } from '@vben/stores';
|
||||||
import '@vben/styles';
|
import '@vben/styles';
|
||||||
@@ -11,6 +12,7 @@ import { useTitle } from '@vueuse/core';
|
|||||||
import { $t, setupI18n } from '#/locales';
|
import { $t, setupI18n } from '#/locales';
|
||||||
|
|
||||||
import { initComponentAdapter } from './adapter/component';
|
import { initComponentAdapter } from './adapter/component';
|
||||||
|
import { initSetupVbenForm } from './adapter/form';
|
||||||
import App from './app.vue';
|
import App from './app.vue';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
|
|
||||||
@@ -18,8 +20,26 @@ async function bootstrap(namespace: string) {
|
|||||||
// 初始化组件适配器
|
// 初始化组件适配器
|
||||||
await initComponentAdapter();
|
await initComponentAdapter();
|
||||||
|
|
||||||
|
// 初始化表单组件
|
||||||
|
await initSetupVbenForm();
|
||||||
|
|
||||||
|
// // 设置弹窗的默认配置
|
||||||
|
// setDefaultModalProps({
|
||||||
|
// fullscreenButton: false,
|
||||||
|
// });
|
||||||
|
// // 设置抽屉的默认配置
|
||||||
|
// setDefaultDrawerProps({
|
||||||
|
// zIndex: 1020,
|
||||||
|
// });
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// 注册v-loading指令
|
||||||
|
registerLoadingDirective(app, {
|
||||||
|
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
|
||||||
|
spinning: 'spinning',
|
||||||
|
});
|
||||||
|
|
||||||
// 国际化 i18n 配置
|
// 国际化 i18n 配置
|
||||||
await setupI18n(app);
|
await setupI18n(app);
|
||||||
|
|
||||||
@@ -29,9 +49,17 @@ async function bootstrap(namespace: string) {
|
|||||||
// 安装权限指令
|
// 安装权限指令
|
||||||
registerAccessDirective(app);
|
registerAccessDirective(app);
|
||||||
|
|
||||||
|
// 初始化 tippy
|
||||||
|
const { initTippy } = await import('@vben/common-ui/es/tippy');
|
||||||
|
initTippy(app);
|
||||||
|
|
||||||
// 配置路由及路由守卫
|
// 配置路由及路由守卫
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
|
// 配置Motion插件
|
||||||
|
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||||
|
app.use(MotionPlugin);
|
||||||
|
|
||||||
// 动态更新标题
|
// 动态更新标题
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (preferences.app.dynamicTitle) {
|
if (preferences.app.dynamicTitle) {
|
||||||
|
|||||||
@@ -0,0 +1,752 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 中药药名气泡选择器
|
||||||
|
* - 对齐诊断/医嘱 EntryKeywordBubble:Input 聚焦弹出 Teleport 浮层
|
||||||
|
* - 远程搜索药品(getProductListDoctorReception),支持药名/别名/首拼/全拼/数字
|
||||||
|
* - 列表展示拼音列;命中别名时展示匹配别名及其拼音
|
||||||
|
* - 选中后回传完整商品行,供接诊/复诊/特色方/常用方复用现有落库逻辑
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
|
import { Empty, Input, Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
rankByMatchScore,
|
||||||
|
splitHighlight,
|
||||||
|
type HighlightPart,
|
||||||
|
} from '#/utils/dictSearchRank';
|
||||||
|
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
||||||
|
|
||||||
|
export type ChineseDrugBubbleItem = {
|
||||||
|
id: number;
|
||||||
|
drug_id: number;
|
||||||
|
price: number;
|
||||||
|
buy_price?: number;
|
||||||
|
drug: Record<string, any>;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 已选药名(展示回填;打开搜索时仍可编辑关键词) */
|
||||||
|
displayName?: string;
|
||||||
|
/** 药品类型:1 中药,颗粒等由调用方传入 */
|
||||||
|
type?: number;
|
||||||
|
storeId?: number;
|
||||||
|
registerId?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 卡片内紧凑宽度 */
|
||||||
|
compact?: boolean;
|
||||||
|
/** 额外 class,便于焦点跳转 querySelector */
|
||||||
|
inputClass?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
displayName: '',
|
||||||
|
type: 1,
|
||||||
|
storeId: 0,
|
||||||
|
registerId: 0,
|
||||||
|
placeholder: '药名',
|
||||||
|
disabled: false,
|
||||||
|
compact: false,
|
||||||
|
inputClass: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [item: ChineseDrugBubbleItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 48;
|
||||||
|
const HINT_HEIGHT = 34;
|
||||||
|
const HEAD_HEIGHT = 32;
|
||||||
|
const PANEL_PAD = 8;
|
||||||
|
const PREFERRED_PANEL = 360;
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const panelRef = ref<HTMLElement | null>(null);
|
||||||
|
const listRef = ref<HTMLElement | null>(null);
|
||||||
|
const inputRef = ref<{ focus?: () => void } | null>(null);
|
||||||
|
const keyword = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const open = ref(false);
|
||||||
|
/** 是否处于「正在搜」:true 时不把 displayName 强写回 keyword */
|
||||||
|
const searching = ref(false);
|
||||||
|
const highlightIndex = ref(0);
|
||||||
|
const options = ref<ChineseDrugBubbleItem[]>([]);
|
||||||
|
const scrollTop = ref(0);
|
||||||
|
const listViewportH = ref(200);
|
||||||
|
const panelStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
const totalHeight = computed(() => options.value.length * ROW_HEIGHT);
|
||||||
|
const startIndex = computed(() =>
|
||||||
|
Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - 2),
|
||||||
|
);
|
||||||
|
const endIndex = computed(() => {
|
||||||
|
const visible = Math.ceil(listViewportH.value / ROW_HEIGHT) + 4;
|
||||||
|
return Math.min(options.value.length - 1, startIndex.value + visible);
|
||||||
|
});
|
||||||
|
const visibleRows = computed(() => {
|
||||||
|
const list = options.value;
|
||||||
|
const start = startIndex.value;
|
||||||
|
const end = endIndex.value;
|
||||||
|
if (list.length === 0 || end < start) return [];
|
||||||
|
return list.slice(start, end + 1).map((item, i) => ({
|
||||||
|
item,
|
||||||
|
index: start + i,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const offsetY = computed(() => startIndex.value * ROW_HEIGHT);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步展示名:未打开搜索时用 displayName 回填输入框
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => props.displayName,
|
||||||
|
(name) => {
|
||||||
|
if (!searching.value && !open.value) {
|
||||||
|
keyword.value = String(name || '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function partsOf(text: string): HighlightPart[] {
|
||||||
|
return splitHighlight(text, keyword.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function drugNameOf(item: ChineseDrugBubbleItem): string {
|
||||||
|
return String(item?.drug?.drug_name || item?.drug_name || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关键词命中的别名(后端 matched_alias) */
|
||||||
|
function matchedAliasOf(item: ChineseDrugBubbleItem): string {
|
||||||
|
return String(
|
||||||
|
item?.matched_alias || item?.drug?.matched_alias || '',
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼音展示:命中别名时用别名拼音,否则用药名首拼(全拼)
|
||||||
|
*/
|
||||||
|
function pinyinLabelOf(item: ChineseDrugBubbleItem): string {
|
||||||
|
const aliasPy = String(
|
||||||
|
item?.matched_alias_pinyin || item?.drug?.matched_alias_pinyin || '',
|
||||||
|
).trim();
|
||||||
|
if (matchedAliasOf(item) && aliasPy) return aliasPy;
|
||||||
|
const display = String(item?.drug?.pinyin_display || '').trim();
|
||||||
|
if (display) return display;
|
||||||
|
const simple = String(item?.drug?.pinyin_simple || '').trim();
|
||||||
|
const full = String(item?.drug?.pinyin_full || '').trim();
|
||||||
|
if (simple && full && simple.toLowerCase() !== full.toLowerCase()) {
|
||||||
|
return `${simple}(${full})`;
|
||||||
|
}
|
||||||
|
return simple || full || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitLabelOf(item: ChineseDrugBubbleItem): string {
|
||||||
|
const unit = item?.drug?.unit || item?.unit;
|
||||||
|
if (typeof unit === 'object' && unit?.name) return String(unit.name);
|
||||||
|
return String(item?.drug?.specification || item?.specification || '').trim() || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceLabelOf(item: ChineseDrugBubbleItem): string {
|
||||||
|
const p = Number(item?.price ?? 0);
|
||||||
|
return Number.isFinite(p) ? `¥${p.toFixed(2)}` : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板 Teleport + fixed,避免接诊/Modal overflow 裁切
|
||||||
|
*/
|
||||||
|
function updatePanelPlacement() {
|
||||||
|
const el = containerRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const gap = 4;
|
||||||
|
const spaceBelow = vh - rect.bottom - PANEL_PAD;
|
||||||
|
const spaceAbove = rect.top - PANEL_PAD;
|
||||||
|
const placeTop = spaceBelow < 180 && spaceAbove > spaceBelow;
|
||||||
|
const avail = Math.max(140, placeTop ? spaceAbove : spaceBelow);
|
||||||
|
const panelMax = Math.min(PREFERRED_PANEL, avail);
|
||||||
|
listViewportH.value = Math.max(80, panelMax - HINT_HEIGHT - HEAD_HEIGHT);
|
||||||
|
|
||||||
|
// 含拼音列后加宽,避免别名/全拼被裁切
|
||||||
|
const PANEL_MIN_WIDTH = 560;
|
||||||
|
const width = Math.min(
|
||||||
|
Math.max(rect.width, PANEL_MIN_WIDTH),
|
||||||
|
vw - PANEL_PAD * 2,
|
||||||
|
);
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + width > vw - PANEL_PAD) {
|
||||||
|
left = vw - PANEL_PAD - width;
|
||||||
|
}
|
||||||
|
if (left < PANEL_PAD) left = PANEL_PAD;
|
||||||
|
|
||||||
|
const base: Record<string, string> = {
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: '4000',
|
||||||
|
width: `${width}px`,
|
||||||
|
maxHeight: `${panelMax}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
right: 'auto',
|
||||||
|
};
|
||||||
|
if (placeTop) {
|
||||||
|
panelStyle.value = {
|
||||||
|
...base,
|
||||||
|
top: 'auto',
|
||||||
|
bottom: `${Math.max(PANEL_PAD, vh - rect.top + gap)}px`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
panelStyle.value = {
|
||||||
|
...base,
|
||||||
|
top: `${rect.bottom + gap}px`,
|
||||||
|
bottom: 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远程搜药;空关键词不请求(与原中药 Select 一致)
|
||||||
|
*/
|
||||||
|
async function fetchDrugs(kw: string) {
|
||||||
|
const name = String(kw || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
options.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getProductListDoctorReception({
|
||||||
|
name,
|
||||||
|
type: props.type,
|
||||||
|
store_id: props.storeId || 0,
|
||||||
|
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
|
||||||
|
});
|
||||||
|
const list = Array.isArray(res) ? res : [];
|
||||||
|
const mapped: ChineseDrugBubbleItem[] = list.map((item: any) => {
|
||||||
|
const drug = item.drug || {};
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: Number(item.id || 0),
|
||||||
|
drug_id: Number(item.drug_id || drug.id || 0),
|
||||||
|
price: Number(item.price ?? 0),
|
||||||
|
buy_price: item.buy_price,
|
||||||
|
drug: {
|
||||||
|
...drug,
|
||||||
|
drug_name: drug.drug_name || item.drug_name || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
options.value = rankByMatchScore(
|
||||||
|
mapped,
|
||||||
|
name,
|
||||||
|
(it) => drugNameOf(it),
|
||||||
|
(it) =>
|
||||||
|
[
|
||||||
|
it.drug?.pinyin_simple,
|
||||||
|
it.drug?.pinyin_full,
|
||||||
|
it.drug?.pinyin_display,
|
||||||
|
it.drug?.drug_alias,
|
||||||
|
it.matched_alias || it.drug?.matched_alias,
|
||||||
|
it.matched_alias_pinyin || it.drug?.matched_alias_pinyin,
|
||||||
|
it.drug?.search_digits,
|
||||||
|
it.drug?.specification,
|
||||||
|
]
|
||||||
|
.map((v) => String(v || ''))
|
||||||
|
.join(' '),
|
||||||
|
);
|
||||||
|
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
||||||
|
scrollTop.value = 0;
|
||||||
|
if (listRef.value) listRef.value.scrollTop = 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('中药药名搜索失败:', e);
|
||||||
|
options.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
nextTick(() => updatePanelPlacement());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedFetch = useDebounceFn((kw: string) => {
|
||||||
|
void fetchDrugs(kw);
|
||||||
|
}, 280);
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
open.value = true;
|
||||||
|
searching.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
updatePanelPlacement();
|
||||||
|
if (keyword.value.trim()) {
|
||||||
|
debouncedFetch(keyword.value);
|
||||||
|
} else {
|
||||||
|
options.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel(restoreDisplay = true) {
|
||||||
|
open.value = false;
|
||||||
|
searching.value = false;
|
||||||
|
options.value = [];
|
||||||
|
highlightIndex.value = 0;
|
||||||
|
if (restoreDisplay) {
|
||||||
|
keyword.value = String(props.displayName || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
// 聚焦时清空为搜索态,便于直接输入新药名
|
||||||
|
if (props.displayName && keyword.value === props.displayName) {
|
||||||
|
keyword.value = '';
|
||||||
|
}
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
searching.value = true;
|
||||||
|
if (!open.value) openPanel();
|
||||||
|
else updatePanelPlacement();
|
||||||
|
debouncedFetch(keyword.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: ChineseDrugBubbleItem) {
|
||||||
|
const name = drugNameOf(item);
|
||||||
|
keyword.value = name;
|
||||||
|
searching.value = false;
|
||||||
|
open.value = false;
|
||||||
|
options.value = [];
|
||||||
|
emit('select', item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘高亮循环移动(与诊断 EntryKeywordBubble 一致:第一项再上键 → 最后一项)
|
||||||
|
*/
|
||||||
|
function moveHighlight(delta: number) {
|
||||||
|
if (options.value.length === 0) return;
|
||||||
|
const len = options.value.length;
|
||||||
|
let next = highlightIndex.value;
|
||||||
|
if (next < 0) next = 0;
|
||||||
|
else next = (next + delta + len) % len;
|
||||||
|
highlightIndex.value = next;
|
||||||
|
ensureRowVisible(highlightIndex.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!open.value) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePanel(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.value.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveHighlight(1);
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveHighlight(-1);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const hit = options.value[highlightIndex.value];
|
||||||
|
if (hit) selectItem(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRowVisible(index: number) {
|
||||||
|
const el = listRef.value;
|
||||||
|
if (!el || index < 0) return;
|
||||||
|
const top = index * ROW_HEIGHT;
|
||||||
|
const bottom = top + ROW_HEIGHT;
|
||||||
|
if (top < el.scrollTop) el.scrollTop = top;
|
||||||
|
else if (bottom > el.scrollTop + el.clientHeight) {
|
||||||
|
el.scrollTop = bottom - el.clientHeight;
|
||||||
|
}
|
||||||
|
// 同步虚拟列表切片(循环跳到首/尾时必须刷新可见区)
|
||||||
|
scrollTop.value = el.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onListScroll() {
|
||||||
|
scrollTop.value = listRef.value?.scrollTop || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocPointerDown(e: MouseEvent) {
|
||||||
|
if (!open.value) return;
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (containerRef.value?.contains(t)) return;
|
||||||
|
if (panelRef.value?.contains(t)) return;
|
||||||
|
closePanel(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWinChange() {
|
||||||
|
if (open.value) updatePanelPlacement();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onDocPointerDown, true);
|
||||||
|
window.addEventListener('resize', onWinChange);
|
||||||
|
window.addEventListener('scroll', onWinChange, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocPointerDown, true);
|
||||||
|
window.removeEventListener('resize', onWinChange);
|
||||||
|
window.removeEventListener('scroll', onWinChange, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus() {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = inputRef.value as any;
|
||||||
|
el?.focus?.();
|
||||||
|
const native = containerRef.value?.querySelector(
|
||||||
|
'input',
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
native?.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
closePanel(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="chinese-drug-name-bubble"
|
||||||
|
:class="[{ 'chinese-drug-name-bubble--compact': compact }, inputClass]"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model:value="keyword"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
allow-clear
|
||||||
|
size="small"
|
||||||
|
@input="handleInput"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<LoadingOutlined v-if="loading" class="text-muted-foreground" />
|
||||||
|
<SearchOutlined v-else class="text-muted-foreground" />
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
ref="panelRef"
|
||||||
|
class="chinese-drug-name-bubble__panel"
|
||||||
|
:style="panelStyle"
|
||||||
|
>
|
||||||
|
<div class="chinese-drug-name-bubble__hint">
|
||||||
|
<span>↑↓←→ 循环切换 · Enter 选用 · Esc 关闭</span>
|
||||||
|
<span class="chinese-drug-name-bubble__count">
|
||||||
|
<template v-if="keyword.trim()">搜到 {{ options.length }} 条</template>
|
||||||
|
<template v-else>输入药名搜索</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Empty
|
||||||
|
v-if="!loading && options.length === 0"
|
||||||
|
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||||
|
:description="
|
||||||
|
keyword.trim()
|
||||||
|
? '未找到匹配药材'
|
||||||
|
: '请输入药名/别名/拼音/数字搜索'
|
||||||
|
"
|
||||||
|
class="py-4"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
class="chinese-drug-name-bubble__head"
|
||||||
|
:class="{ 'has-score': !!keyword.trim() }"
|
||||||
|
>
|
||||||
|
<span class="col-name">药名</span>
|
||||||
|
<span class="col-py">拼音</span>
|
||||||
|
<span class="col-spec">规格/单位</span>
|
||||||
|
<span class="col-price">价格</span>
|
||||||
|
<span v-if="keyword.trim()" class="col-score">匹配度</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="listRef"
|
||||||
|
class="chinese-drug-name-bubble__list"
|
||||||
|
:style="{ height: `${listViewportH}px` }"
|
||||||
|
@scroll="onListScroll"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="chinese-drug-name-bubble__phantom"
|
||||||
|
:style="{ height: `${totalHeight}px` }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="chinese-drug-name-bubble__virtual"
|
||||||
|
:style="{ transform: `translateY(${offsetY}px)` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="{ item, index } in visibleRows"
|
||||||
|
:key="`${item.drug_id}_${index}`"
|
||||||
|
class="chinese-drug-name-bubble__row"
|
||||||
|
:class="{
|
||||||
|
'is-active': index === highlightIndex,
|
||||||
|
'has-score': !!keyword.trim(),
|
||||||
|
}"
|
||||||
|
:style="{ height: `${ROW_HEIGHT}px` }"
|
||||||
|
@mousedown.prevent="selectItem(item)"
|
||||||
|
@mouseenter="highlightIndex = index"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="col-name"
|
||||||
|
:title="
|
||||||
|
matchedAliasOf(item)
|
||||||
|
? `${drugNameOf(item)}(别名:${matchedAliasOf(item)})`
|
||||||
|
: drugNameOf(item)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="col-name__main">
|
||||||
|
<template
|
||||||
|
v-for="(p, pi) in partsOf(drugNameOf(item))"
|
||||||
|
:key="`n_${pi}`"
|
||||||
|
>
|
||||||
|
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||||
|
<template v-else>{{ p.text }}</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="matchedAliasOf(item)"
|
||||||
|
class="col-name__alias"
|
||||||
|
>
|
||||||
|
别名:
|
||||||
|
<template
|
||||||
|
v-for="(p, pi) in partsOf(matchedAliasOf(item))"
|
||||||
|
:key="`a_${pi}`"
|
||||||
|
>
|
||||||
|
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||||
|
<template v-else>{{ p.text }}</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="col-py" :title="pinyinLabelOf(item)">
|
||||||
|
<template v-if="pinyinLabelOf(item) === '-'">-</template>
|
||||||
|
<template v-else>
|
||||||
|
<template
|
||||||
|
v-for="(p, pi) in partsOf(pinyinLabelOf(item))"
|
||||||
|
:key="`py_${pi}`"
|
||||||
|
>
|
||||||
|
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||||
|
<template v-else>{{ p.text }}</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="col-spec" :title="unitLabelOf(item)">
|
||||||
|
{{ unitLabelOf(item) }}
|
||||||
|
</span>
|
||||||
|
<span class="col-price">{{ priceLabelOf(item) }}</span>
|
||||||
|
<span v-if="keyword.trim()" class="col-score">
|
||||||
|
{{ Number(item.match_score || 0) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chinese-drug-name-bubble {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* 锁死宽度:避免接诊/复诊/特色方/常用方 flex 布局把药名框撑满整行 */
|
||||||
|
width: 100px !important;
|
||||||
|
min-width: 88px !important;
|
||||||
|
max-width: 110px !important;
|
||||||
|
flex: 0 0 100px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble--compact {
|
||||||
|
width: 100px !important;
|
||||||
|
min-width: 88px !important;
|
||||||
|
max-width: 110px !important;
|
||||||
|
flex: 0 0 100px !important;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble :deep(.ant-input-affix-wrapper) {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
padding-inline: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble :deep(.ant-input-prefix) {
|
||||||
|
margin-inline-end: 2px;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble :deep(.ant-input) {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__panel {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--popover, var(--background)));
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__hint {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.35);
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__list {
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__phantom {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__virtual {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__head,
|
||||||
|
.chinese-drug-name-bubble__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(90px, 1.1fr) minmax(110px, 1.3fr) 90px 64px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__head.has-score,
|
||||||
|
.chinese-drug-name-bubble__row.has-score {
|
||||||
|
grid-template-columns: minmax(90px, 1.1fr) minmax(100px, 1.2fr) 80px 56px 48px;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: hsl(var(--popover, var(--background)));
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__row {
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid hsl(var(--border) / 0.6);
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__row:hover,
|
||||||
|
.chinese-drug-name-bubble__row.is-active {
|
||||||
|
background: hsl(var(--accent));
|
||||||
|
color: hsl(var(--accent-foreground));
|
||||||
|
}
|
||||||
|
.col-name,
|
||||||
|
.col-py,
|
||||||
|
.col-spec {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.col-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.col-name__main {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.col-name__alias {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.col-py {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.col-spec {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.col-price {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.col-score {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d4380d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__row.is-active .col-spec,
|
||||||
|
.chinese-drug-name-bubble__row.is-active .col-py,
|
||||||
|
.chinese-drug-name-bubble__row.is-active .col-name__alias {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.chinese-drug-name-bubble__row.is-active .col-score {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.hit {
|
||||||
|
font-style: normal;
|
||||||
|
color: #cf1322;
|
||||||
|
background: rgba(255, 214, 102, 0.55);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 中药药名气泡选择器
|
||||||
|
*/
|
||||||
|
export { default as ChineseDrugNameBubble } from './chinese-drug-name-bubble.vue';
|
||||||
|
export type { ChineseDrugBubbleItem } from './chinese-drug-name-bubble.vue';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 医生端 PC 传方悬浮窗:悬浮按钮 + 子窗口列表 + 状态操作
|
* 医生端 PC 传方入口:顶栏按钮 + 子窗口列表 + 状态操作
|
||||||
|
* 权限:有 doctor_id 且绑定 store_id 时由外层 v-if 控制,本组件只负责展示与操作
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
ZoomInOutlined,
|
ZoomInOutlined,
|
||||||
ZoomOutOutlined,
|
ZoomOutOutlined,
|
||||||
@@ -19,6 +21,7 @@ import { usePreferences } from '@vben/preferences';
|
|||||||
import { useUserStore } from '@vben/stores';
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Image,
|
Image,
|
||||||
@@ -29,10 +32,10 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Tag,
|
Tag,
|
||||||
Textarea,
|
Textarea,
|
||||||
|
Tooltip,
|
||||||
message,
|
message,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
import FloatFab from '#/components/float-fab/FloatFab.vue';
|
|
||||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SUB_WINDOW_Z_INDEX,
|
DEFAULT_SUB_WINDOW_Z_INDEX,
|
||||||
@@ -355,12 +358,20 @@ defineExpose({ loadList });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FloatFab
|
<div class="doctor-transfer-header-entry">
|
||||||
storage-key="pc_doctor_transfer_fab"
|
<Tooltip title="门店传方">
|
||||||
label="传方"
|
<Badge :count="pendingCount" :overflow-count="99" size="small">
|
||||||
:badge="pendingCount"
|
<Button
|
||||||
@click="openWindow"
|
type="text"
|
||||||
/>
|
class="transfer-header-btn"
|
||||||
|
@click="openWindow"
|
||||||
|
>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span class="transfer-header-btn__text">传方</span>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<SubWindow
|
<SubWindow
|
||||||
v-model:open="windowOpen"
|
v-model:open="windowOpen"
|
||||||
title="门店传方"
|
title="门店传方"
|
||||||
@@ -848,4 +859,25 @@ defineExpose({ loadList });
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.doctor-transfer-header-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.transfer-header-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.transfer-header-btn__text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.transfer-header-btn__text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,19 +6,20 @@
|
|||||||
* - 支持中药/西药类型切换
|
* - 支持中药/西药类型切换
|
||||||
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
|
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
|
||||||
* - 选择时返回完整药品数据(包含默认用法字段)
|
* - 选择时返回完整药品数据(包含默认用法字段)
|
||||||
|
* - isCard:单选确认场景(如仓绑药)选中后以内嵌卡片展示已选药品
|
||||||
* @author 系统
|
* @author 系统
|
||||||
* @date 2024
|
* @date 2024
|
||||||
*/
|
*/
|
||||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||||
import { Input, Spin } from 'ant-design-vue';
|
import { Button, Input, Spin } from 'ant-design-vue';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
||||||
|
|
||||||
/** 中药无图时与仓库列表一致的占位图 */
|
/** 无图时占位(中药/西药等统一兜底,避免仓绑药下拉只剩文字) */
|
||||||
const TCM_PLACEHOLDER_IMAGE =
|
const DRUG_PLACEHOLDER_IMAGE =
|
||||||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
||||||
|
|
||||||
// ==================== Props 定义 ====================
|
// ==================== Props 定义 ====================
|
||||||
@@ -29,12 +30,17 @@ interface Props {
|
|||||||
* 1-中药,2-西药
|
* 1-中药,2-西药
|
||||||
*/
|
*/
|
||||||
type?: number;
|
type?: number;
|
||||||
|
/**
|
||||||
|
* 多药品类型(优先于 type;有值时支持空关键词拉全量)
|
||||||
|
* 例如 [2,4] 表示西药+中成药
|
||||||
|
*/
|
||||||
|
types?: number[];
|
||||||
/**
|
/**
|
||||||
* 占位提示文本
|
* 占位提示文本
|
||||||
*/
|
*/
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/**
|
/**
|
||||||
* 诊所ID
|
* 诊所ID(接诊/开方必传;仓药绑定等平台场景可省略,默认 0 走主库 types 检索)
|
||||||
*/
|
*/
|
||||||
storeId?: number;
|
storeId?: number;
|
||||||
/**
|
/**
|
||||||
@@ -45,14 +51,21 @@ interface Props {
|
|||||||
* 是否禁用
|
* 是否禁用
|
||||||
*/
|
*/
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否以卡片展示已选药品(仓绑药等单选确认场景;开方默认 false)
|
||||||
|
*/
|
||||||
|
isCard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
type: 1,
|
type: 1,
|
||||||
|
types: () => [],
|
||||||
placeholder: '输入药品名称搜索',
|
placeholder: '输入药品名称搜索',
|
||||||
storeId: 2,
|
// 默认 0:平台无门店;诊所场景由调用方传 :store-id
|
||||||
|
storeId: 0,
|
||||||
registerId: undefined,
|
registerId: undefined,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
isCard: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== Emits 定义 ====================
|
// ==================== Emits 定义 ====================
|
||||||
@@ -97,10 +110,21 @@ const containerRef = ref<HTMLElement | null>(null);
|
|||||||
*/
|
*/
|
||||||
const highlightIndex = ref(-1);
|
const highlightIndex = ref(-1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isCard 模式下已选药品(用于输入框下方卡片)
|
||||||
|
*/
|
||||||
|
const selectedDrug = ref<any | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索框引用,便于「重新选择」时聚焦
|
||||||
|
*/
|
||||||
|
const inputRef = ref<{ focus?: () => void } | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析下拉展示图:优先真图,无图用统一占位
|
||||||
|
*/
|
||||||
function resolveDropdownImage(item: any): string {
|
function resolveDropdownImage(item: any): string {
|
||||||
if (item._image) return item._image;
|
return item._image || DRUG_PLACEHOLDER_IMAGE;
|
||||||
if (props.type === 1) return TCM_PLACEHOLDER_IMAGE;
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 方法定义 ====================
|
// ==================== 方法定义 ====================
|
||||||
@@ -108,10 +132,12 @@ function resolveDropdownImage(item: any): string {
|
|||||||
/**
|
/**
|
||||||
* 搜索药品
|
* 搜索药品
|
||||||
* @param keyword 搜索关键词
|
* @param keyword 搜索关键词
|
||||||
* @description 调用后端API搜索药品,支持按名称和拼音搜索
|
* @description 调用后端API搜索药品,支持按名称和拼音搜索;
|
||||||
|
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
|
||||||
*/
|
*/
|
||||||
const searchDrugs = debounce(async (keyword: string) => {
|
const searchDrugs = debounce(async (keyword: string) => {
|
||||||
if (!keyword || keyword.length < 1) {
|
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
|
||||||
|
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
return;
|
return;
|
||||||
@@ -121,37 +147,43 @@ const searchDrugs = debounce(async (keyword: string) => {
|
|||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// types 优先:有多类型时只传 types,不传 type,避免后端被单 type 覆盖
|
||||||
|
const typeParams =
|
||||||
|
props.types.length > 0
|
||||||
|
? { types: props.types }
|
||||||
|
: { type: props.type };
|
||||||
const res = await getProductListDoctorReception({
|
const res = await getProductListDoctorReception({
|
||||||
name: keyword,
|
name: keyword || '',
|
||||||
type: props.type,
|
...typeParams,
|
||||||
store_id: props.storeId,
|
store_id: props.storeId,
|
||||||
...(props.registerId ? { register_id: props.registerId } : {}),
|
...(props.registerId ? { register_id: props.registerId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理返回数据
|
// 处理返回数据(兼容门店 relation 与平台主库打平结构)
|
||||||
if (Array.isArray(res)) {
|
if (Array.isArray(res)) {
|
||||||
searchResults.value = res.map((item: any) => ({
|
searchResults.value = res.map((item: any) => {
|
||||||
// 保留原始数据
|
const drug = item.drug || {};
|
||||||
...item,
|
return {
|
||||||
// 提取常用字段便于访问
|
...item,
|
||||||
_id: item.id,
|
_id: item.id,
|
||||||
_drugId: item.drug_id || item.drug?.id,
|
_drugId: item.drug_id || drug.id || item.id,
|
||||||
_drugName: item.drug?.drug_name || item.drug_name || '',
|
_drugName: drug.drug_name || item.drug_name || '',
|
||||||
_image: item.drug?.image || '',
|
_image: drug.image || item.image || '',
|
||||||
_specification: item.drug?.specification || '',
|
_specification: drug.specification || item.specification || '',
|
||||||
_supplier: item.drug?.supplier?.name || '',
|
_supplier: drug.supplier?.name || item.supplier?.name || '',
|
||||||
_price: item.price || 0,
|
_price: Number(item.price ?? 0),
|
||||||
// 默认用法字段
|
// 已绑定配送仓:列表展示「多仓」标识
|
||||||
_timeId: item.drug?.time_id || 0,
|
_hasDeliveryWarehouse: Number(item.has_delivery_warehouse ?? 0) === 1,
|
||||||
_typeId: item.drug?.type_id || 0,
|
_timeId: drug.time_id || 0,
|
||||||
_frequencyId: item.drug?.frequency_id || 0,
|
_typeId: drug.type_id || 0,
|
||||||
_unitId: item.drug?.unit_id || 0,
|
_frequencyId: drug.frequency_id || 0,
|
||||||
_number: item.drug?.number || 1,
|
_unitId: drug.unit_id || 0,
|
||||||
// 用法名称
|
_number: drug.number || 1,
|
||||||
_useNum: item.drug?.useNum,
|
_useNum: drug.useNum,
|
||||||
_useType: item.drug?.useType,
|
_useType: drug.useType,
|
||||||
_useFrequency: item.drug?.useFrequency,
|
_useFrequency: drug.useFrequency,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
}
|
}
|
||||||
@@ -176,8 +208,14 @@ function handleInput(e: Event) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理输入框获得焦点
|
* 处理输入框获得焦点
|
||||||
|
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
|
||||||
*/
|
*/
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
|
if (props.types.length > 0) {
|
||||||
|
searchDrugs('');
|
||||||
|
showDropdown.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (searchResults.value.length > 0) {
|
if (searchResults.value.length > 0) {
|
||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
}
|
}
|
||||||
@@ -186,15 +224,36 @@ function handleFocus() {
|
|||||||
/**
|
/**
|
||||||
* 处理选中药品
|
* 处理选中药品
|
||||||
* @param drug 选中的药品数据
|
* @param drug 选中的药品数据
|
||||||
|
* @description isCard 时保留 selectedDrug 用于卡片展示;否则清空选中态
|
||||||
*/
|
*/
|
||||||
function handleSelectDrug(drug: any) {
|
function handleSelectDrug(drug: any) {
|
||||||
emit('select', drug);
|
emit('select', drug);
|
||||||
|
|
||||||
// 清空搜索状态
|
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
highlightIndex.value = -1;
|
highlightIndex.value = -1;
|
||||||
|
if (props.isCard) {
|
||||||
|
selectedDrug.value = drug;
|
||||||
|
} else {
|
||||||
|
selectedDrug.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空已选卡片并重新搜索(仅 isCard)
|
||||||
|
* 同步 emit null,便于父级清空 drug_id
|
||||||
|
*/
|
||||||
|
function handleReselect() {
|
||||||
|
selectedDrug.value = null;
|
||||||
|
searchKeyword.value = '';
|
||||||
|
searchResults.value = [];
|
||||||
|
showDropdown.value = false;
|
||||||
|
highlightIndex.value = -1;
|
||||||
|
emit('select', null);
|
||||||
|
// 下一帧聚焦搜索框,方便重新选药
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.value?.focus?.();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,6 +320,7 @@ watch(
|
|||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
|
selectedDrug.value = null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -269,6 +329,7 @@ watch(
|
|||||||
<div ref="containerRef" class="drug-search-select">
|
<div ref="containerRef" class="drug-search-select">
|
||||||
<!-- 搜索输入框 -->
|
<!-- 搜索输入框 -->
|
||||||
<Input
|
<Input
|
||||||
|
ref="inputRef"
|
||||||
v-model:value="searchKeyword"
|
v-model:value="searchKeyword"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -283,6 +344,49 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</Input>
|
</Input>
|
||||||
|
|
||||||
|
<!-- isCard:已选药品卡片(与下拉项同结构,带边框区分) -->
|
||||||
|
<div
|
||||||
|
v-if="isCard && selectedDrug"
|
||||||
|
class="drug-item drug-item--selected-card"
|
||||||
|
>
|
||||||
|
<div class="drug-item__image">
|
||||||
|
<img
|
||||||
|
:src="resolveDropdownImage(selectedDrug)"
|
||||||
|
alt=""
|
||||||
|
class="drug-item__img"
|
||||||
|
@error="
|
||||||
|
(e) => ((e.target as HTMLImageElement).src = DRUG_PLACEHOLDER_IMAGE)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="drug-item__info">
|
||||||
|
<div class="drug-item__name">{{ selectedDrug._drugName }}</div>
|
||||||
|
<div class="drug-item__meta">
|
||||||
|
<span class="drug-item__id">ID:{{ selectedDrug._drugId }}</span>
|
||||||
|
<span v-if="selectedDrug._specification" class="drug-item__spec">
|
||||||
|
规格:{{ selectedDrug._specification }}
|
||||||
|
</span>
|
||||||
|
<span v-if="selectedDrug._supplier" class="drug-item__supplier">
|
||||||
|
供应商:{{ selectedDrug._supplier }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drug-item__price">
|
||||||
|
<template v-if="Number(selectedDrug._price) > 0">
|
||||||
|
¥{{ Number(selectedDrug._price).toFixed(2) }}
|
||||||
|
</template>
|
||||||
|
<span v-else class="drug-item__price--empty">暂无</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
class="drug-item__reselect"
|
||||||
|
@click="handleReselect"
|
||||||
|
>
|
||||||
|
重新选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 下拉列表 -->
|
<!-- 下拉列表 -->
|
||||||
<div v-if="showDropdown" class="drug-dropdown">
|
<div v-if="showDropdown" class="drug-dropdown">
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
@@ -321,12 +425,14 @@ watch(
|
|||||||
<div v-else class="drug-item__img-placeholder">无图</div>
|
<div v-else class="drug-item__img-placeholder">无图</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 药品信息 -->
|
<!-- 药品信息:药名 / 多仓 / ID / 规格 / 供应商 -->
|
||||||
<div class="drug-item__info">
|
<div class="drug-item__info">
|
||||||
<!-- 药品名称 -->
|
<div class="drug-item__name-row">
|
||||||
<div class="drug-item__name">{{ item._drugName }}</div>
|
<div class="drug-item__name">{{ item._drugName }}</div>
|
||||||
<!-- 规格和供应商 -->
|
<span v-if="item._hasDeliveryWarehouse" class="drug-item__multi-wh">多仓</span>
|
||||||
|
</div>
|
||||||
<div class="drug-item__meta">
|
<div class="drug-item__meta">
|
||||||
|
<span class="drug-item__id">ID:{{ item._drugId }}</span>
|
||||||
<span v-if="item._specification" class="drug-item__spec">
|
<span v-if="item._specification" class="drug-item__spec">
|
||||||
规格:{{ item._specification }}
|
规格:{{ item._specification }}
|
||||||
</span>
|
</span>
|
||||||
@@ -336,9 +442,12 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 价格 -->
|
<!-- 价格:无价(平台主库常见)展示「暂无」 -->
|
||||||
<div class="drug-item__price">
|
<div class="drug-item__price">
|
||||||
¥{{ Number(item._price).toFixed(2) }}
|
<template v-if="Number(item._price) > 0">
|
||||||
|
¥{{ Number(item._price).toFixed(2) }}
|
||||||
|
</template>
|
||||||
|
<span v-else class="drug-item__price--empty">暂无</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,6 +456,7 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* 只用框架主题变量,随亮暗/主题色自动适配,不写 .dark 硬编码覆盖 */
|
||||||
.drug-search-select {
|
.drug-search-select {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -359,8 +469,8 @@ watch(
|
|||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: #fff;
|
background: hsl(var(--card));
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
@@ -373,7 +483,7 @@ watch(
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
color: #999;
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +497,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: #d9d9d9;
|
background: hsl(var(--border));
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +517,25 @@ watch(
|
|||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&--active {
|
&--active {
|
||||||
background-color: #f5f7fa;
|
background-color: hsl(var(--accent-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已选确认卡片:固定展示,非下拉浮层 */
|
||||||
|
&--selected-card {
|
||||||
|
margin-top: 8px;
|
||||||
|
cursor: default;
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reselect {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__image {
|
&__image {
|
||||||
@@ -421,7 +549,7 @@ watch(
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #f0f0f0;
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__img-placeholder {
|
&__img-placeholder {
|
||||||
@@ -430,10 +558,10 @@ watch(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f5f5f5;
|
background: hsl(var(--accent));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #bbb;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
@@ -442,14 +570,35 @@ watch(
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: hsl(var(--foreground));
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-bottom: 4px;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 配送仓绑定药品标识:小号胶囊,不抢主信息 */
|
||||||
|
&__multi-wh {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #2b6de5;
|
||||||
|
background: rgba(43, 109, 229, 0.08);
|
||||||
|
border: 1px solid rgba(43, 109, 229, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__meta {
|
||||||
@@ -457,57 +606,31 @@ watch(
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__id {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__spec {
|
&__spec {
|
||||||
color: #666;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__supplier {
|
&__supplier {
|
||||||
color: #1890ff;
|
color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__price {
|
&__price {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ff4d4f;
|
color: hsl(var(--destructive));
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色模式适配 */
|
&--empty {
|
||||||
.dark {
|
font-size: 13px;
|
||||||
.drug-dropdown {
|
font-weight: 400;
|
||||||
background: #1f2937;
|
color: hsl(var(--muted-foreground));
|
||||||
border-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drug-item {
|
|
||||||
&:hover,
|
|
||||||
&--active {
|
|
||||||
background-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__img-placeholder {
|
|
||||||
background: #374151;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
color: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__spec {
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__supplier {
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 通用文件预览:图片用 Ant Image;PDF fetch→blob→iframe;支持多文件切换
|
||||||
|
* 开票中心与素材库复用,避免两套预览逻辑
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Button, Image, Modal, Spin, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 是否显示 */
|
||||||
|
open: boolean;
|
||||||
|
/** 文件 URL 列表 */
|
||||||
|
files?: string[];
|
||||||
|
/** 弹窗标题前缀 */
|
||||||
|
title?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
files: () => [],
|
||||||
|
title: '文件预览',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', v: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const previewIsImage = ref(false);
|
||||||
|
const previewImageUrl = ref('');
|
||||||
|
const previewPdfUrl = ref('');
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.open,
|
||||||
|
set: (v: boolean) => emit('update:open', v),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUrl = computed(() => props.files?.[currentIndex.value] || '');
|
||||||
|
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
const name = (currentUrl.value || '').split('/').pop() || '';
|
||||||
|
const total = props.files?.length || 0;
|
||||||
|
if (total > 1) {
|
||||||
|
return `${props.title}(${currentIndex.value + 1}/${total})${name ? ' - ' + name : ''}`;
|
||||||
|
}
|
||||||
|
return name ? `${props.title} - ${name}` : props.title;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isImageUrl(url: string) {
|
||||||
|
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokePdfBlob() {
|
||||||
|
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewPdfUrl.value);
|
||||||
|
}
|
||||||
|
previewPdfUrl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载当前文件到预览区
|
||||||
|
*/
|
||||||
|
async function loadCurrent() {
|
||||||
|
const url = currentUrl.value;
|
||||||
|
revokePdfBlob();
|
||||||
|
if (!url) {
|
||||||
|
message.info('暂无文件');
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isImageUrl(url)) {
|
||||||
|
previewIsImage.value = true;
|
||||||
|
previewImageUrl.value = url;
|
||||||
|
previewLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewIsImage.value = false;
|
||||||
|
previewLoading.value = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error('文件加载失败');
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const pdfBlob =
|
||||||
|
blob.type && blob.type !== 'application/octet-stream'
|
||||||
|
? blob
|
||||||
|
: new Blob([blob], { type: 'application/pdf' });
|
||||||
|
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
|
||||||
|
} catch {
|
||||||
|
message.error('预览失败,请尝试下载后查看');
|
||||||
|
visible.value = false;
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevFile() {
|
||||||
|
if (currentIndex.value > 0) {
|
||||||
|
currentIndex.value -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFile() {
|
||||||
|
if (props.files && currentIndex.value < props.files.length - 1) {
|
||||||
|
currentIndex.value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.files] as const,
|
||||||
|
([open]) => {
|
||||||
|
if (open) {
|
||||||
|
currentIndex.value = 0;
|
||||||
|
loadCurrent();
|
||||||
|
} else {
|
||||||
|
revokePdfBlob();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(currentIndex, () => {
|
||||||
|
if (props.open) {
|
||||||
|
loadCurrent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
revokePdfBlob();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
v-model:open="visible"
|
||||||
|
:title="displayTitle"
|
||||||
|
:footer="null"
|
||||||
|
width="860px"
|
||||||
|
destroy-on-close
|
||||||
|
@cancel="handleClose"
|
||||||
|
>
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div v-if="(files?.length || 0) > 1" class="nav">
|
||||||
|
<Button size="small" :disabled="currentIndex <= 0" @click="prevFile">上一份</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
:disabled="currentIndex >= (files?.length || 0) - 1"
|
||||||
|
@click="nextFile"
|
||||||
|
>
|
||||||
|
下一份
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Spin :spinning="previewLoading">
|
||||||
|
<div v-if="previewIsImage" class="img-box">
|
||||||
|
<Image :src="previewImageUrl" :preview="true" style="max-width: 100%" />
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-else-if="previewPdfUrl"
|
||||||
|
:src="previewPdfUrl"
|
||||||
|
class="pdf-frame"
|
||||||
|
title="pdf-preview"
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.preview-wrap {
|
||||||
|
min-height: 240px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.img-box {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--muted) / 0.25);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
.pdf-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 70vh;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CustomComponentType } from './types';
|
|
||||||
|
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
|
import type { CustomComponentType } from './types';
|
||||||
|
|
||||||
import { getFileNameWithoutExtension, toPascalCase } from '#/util/tool';
|
import { getFileNameWithoutExtension, toPascalCase } from '#/util/tool';
|
||||||
|
|
||||||
const componentMap = new Map<CustomComponentType | string, Component>();
|
const componentMap = new Map<CustomComponentType | string, Component>();
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const handleRemove = (e: Event) => {
|
|||||||
</div>
|
</div>
|
||||||
<Icon v-else icon="ant-design:plus-outlined" />
|
<Icon v-else icon="ant-design:plus-outlined" />
|
||||||
</Upload>
|
</Upload>
|
||||||
<GalleryPickLink @select="onGallerySelect" />
|
<GalleryPickLink :accept-types="[0]" @select="onGallerySelect" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { preferences } from '@vben/preferences';
|
||||||
|
|
||||||
import { QuillEditor } from '@vueup/vue-quill';
|
import { QuillEditor } from '@vueup/vue-quill';
|
||||||
// eslint-disable-next-line n/no-extraneous-import
|
|
||||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
|
||||||
import { useVModel } from '@vueuse/core';
|
import { useVModel } from '@vueuse/core';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ import { message } from 'ant-design-vue';
|
|||||||
import { uploadFile } from '#/api/core/upload';
|
import { uploadFile } from '#/api/core/upload';
|
||||||
import { uploadToOss } from '#/utils/oss-upload';
|
import { uploadToOss } from '#/utils/oss-upload';
|
||||||
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
|
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
|
||||||
import { preferences } from '@vben/preferences';
|
|
||||||
|
// eslint-disable-next-line n/no-extraneous-import
|
||||||
|
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
value: {
|
value: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|||||||
import { useVModel } from '@vueuse/core';
|
import { useVModel } from '@vueuse/core';
|
||||||
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
|
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { matchExpressByTrackingNo } from '#/utils/matchExpressByTrackingNo';
|
||||||
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
|
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -12,9 +13,11 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
value?: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
|
||||||
|
trackingNo?: string;
|
||||||
|
value?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
@@ -24,9 +27,9 @@ const emits = defineEmits<{
|
|||||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||||
|
|
||||||
type ExpressCompanyOption = {
|
type ExpressCompanyOption = {
|
||||||
|
code: string;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
@@ -34,8 +37,13 @@ const loading = ref(false);
|
|||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const options = ref<ExpressCompanyOption[]>([]);
|
const options = ref<ExpressCompanyOption[]>([]);
|
||||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||||
|
/** 用户是否已手动选择/清空;为 true 时不再按单号自动覆盖 */
|
||||||
|
const userPicked = ref(false);
|
||||||
|
|
||||||
function filterExpressCompanyOptions(keyword: string, list: ExpressCompanyOption[]) {
|
function filterExpressCompanyOptions(
|
||||||
|
keyword: string,
|
||||||
|
list: ExpressCompanyOption[],
|
||||||
|
) {
|
||||||
const q = keyword.trim().toLowerCase();
|
const q = keyword.trim().toLowerCase();
|
||||||
if (!q) return list;
|
if (!q) return list;
|
||||||
return list.filter((item) => {
|
return list.filter((item) => {
|
||||||
@@ -56,20 +64,39 @@ const selectedOption = computed(() =>
|
|||||||
const displayText = computed(() => {
|
const displayText = computed(() => {
|
||||||
const item = selectedOption.value;
|
const item = selectedOption.value;
|
||||||
if (!item) return '';
|
if (!item) return '';
|
||||||
return item.code ? `${item.name || '-'}(${item.code})` : (item.name || '-');
|
return item.code ? `${item.name || '-'}(${item.code})` : item.name || '-';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 trackingNo 自动选中快递公司
|
||||||
|
* 规则:尚未手动改选,或当前选中仍与规则结果一致时才覆盖
|
||||||
|
*/
|
||||||
|
function tryAutoMatchByTrackingNo() {
|
||||||
|
if (options.value.length === 0) return;
|
||||||
|
const matched = matchExpressByTrackingNo(props.trackingNo, options.value);
|
||||||
|
if (!matched?.code) return;
|
||||||
|
const matchedCode = matched.code;
|
||||||
|
if (userPicked.value && mValue.value && mValue.value !== matchedCode) {
|
||||||
|
// 用户已选其他公司,不覆盖
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mValue.value === matchedCode) return;
|
||||||
|
mValue.value = matchedCode;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getExpressCompaniesOption({});
|
const res = await getExpressCompaniesOption({});
|
||||||
options.value = Array.isArray(res) ? res : [];
|
options.value = Array.isArray(res) ? res : [];
|
||||||
|
tryAutoMatchByTrackingNo();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOption(item: ExpressCompanyOption) {
|
function selectOption(item: ExpressCompanyOption) {
|
||||||
|
userPicked.value = true;
|
||||||
mValue.value = item.code;
|
mValue.value = item.code;
|
||||||
open.value = false;
|
open.value = false;
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
@@ -77,6 +104,7 @@ function selectOption(item: ExpressCompanyOption) {
|
|||||||
|
|
||||||
function clearSelection(e: Event) {
|
function clearSelection(e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
userPicked.value = true;
|
||||||
mValue.value = undefined;
|
mValue.value = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +113,7 @@ function onOpenChange(next: boolean) {
|
|||||||
open.value = next;
|
open.value = next;
|
||||||
if (next) {
|
if (next) {
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
if (!options.value.length) {
|
if (options.value.length === 0) {
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}
|
}
|
||||||
nextTick(() => searchInputRef.value?.focus?.());
|
nextTick(() => searchInputRef.value?.focus?.());
|
||||||
@@ -99,6 +127,14 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.trackingNo,
|
||||||
|
() => {
|
||||||
|
// 单号变化时允许再自动匹配(除非用户已改选为不一致公司)
|
||||||
|
tryAutoMatchByTrackingNo();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadOptions();
|
loadOptions();
|
||||||
});
|
});
|
||||||
@@ -107,9 +143,9 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Popover
|
<Popover
|
||||||
:open="open"
|
:open="open"
|
||||||
trigger="click"
|
|
||||||
placement="bottomLeft"
|
|
||||||
overlay-class-name="express-company-select-popover"
|
overlay-class-name="express-company-select-popover"
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
@open-change="onOpenChange"
|
@open-change="onOpenChange"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
@@ -118,17 +154,17 @@ onMounted(() => {
|
|||||||
ref="searchInputRef"
|
ref="searchInputRef"
|
||||||
v-model:value="searchKeyword"
|
v-model:value="searchKeyword"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="搜索公司名称或编码"
|
|
||||||
class="express-search"
|
class="express-search"
|
||||||
|
placeholder="搜索公司名称或编码"
|
||||||
/>
|
/>
|
||||||
<Spin :spinning="loading">
|
<Spin :spinning="loading">
|
||||||
<div v-if="filteredOptions.length" class="express-list">
|
<div v-if="filteredOptions.length > 0" class="express-list">
|
||||||
<button
|
<button
|
||||||
v-for="item in filteredOptions"
|
v-for="item in filteredOptions"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
type="button"
|
|
||||||
class="express-item"
|
|
||||||
:class="{ active: item.code === mValue }"
|
:class="{ active: item.code === mValue }"
|
||||||
|
class="express-item"
|
||||||
|
type="button"
|
||||||
@click="selectOption(item)"
|
@click="selectOption(item)"
|
||||||
>
|
>
|
||||||
<div class="express-meta">
|
<div class="express-meta">
|
||||||
@@ -137,20 +173,20 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Empty v-else description="无匹配快递公司" class="express-empty" />
|
<Empty v-else class="express-empty" description="无匹配快递公司" />
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
|
:class="{ disabled, placeholder: !displayText }"
|
||||||
class="express-trigger"
|
class="express-trigger"
|
||||||
:class="{ disabled: disabled, placeholder: !displayText }"
|
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
:value="displayText"
|
|
||||||
readonly
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:placeholder="placeholder || '请选择快递公司'"
|
:placeholder="placeholder || '请选择快递公司'"
|
||||||
|
:value="displayText"
|
||||||
class="express-trigger-input"
|
class="express-trigger-input"
|
||||||
|
readonly
|
||||||
>
|
>
|
||||||
<template v-if="displayText && !disabled" #suffix>
|
<template v-if="displayText && !disabled" #suffix>
|
||||||
<span class="clear-btn" @click="clearSelection">×</span>
|
<span class="clear-btn" @click="clearSelection">×</span>
|
||||||
@@ -163,6 +199,8 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.express-trigger {
|
.express-trigger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* min-width: fit-content; */
|
||||||
|
min-width: 250px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.express-trigger.disabled {
|
.express-trigger.disabled {
|
||||||
@@ -177,7 +215,7 @@ onMounted(() => {
|
|||||||
.clear-btn {
|
.clear-btn {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #86909c;
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -203,10 +241,11 @@ onMounted(() => {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
.express-item:hover,
|
.express-item:hover,
|
||||||
.express-item.active {
|
.express-item.active {
|
||||||
background: #f2f3f5;
|
background: hsl(var(--accent));
|
||||||
}
|
}
|
||||||
.express-meta {
|
.express-meta {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -214,11 +253,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.express-name {
|
.express-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1d2129;
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
.express-sub {
|
.express-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #86909c;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
.express-empty {
|
.express-empty {
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
@@ -228,5 +267,8 @@ onMounted(() => {
|
|||||||
<style>
|
<style>
|
||||||
.express-company-select-popover .ant-popover-inner {
|
.express-company-select-popover .ant-popover-inner {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const props = withDefaults(
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file_type.value 过滤)
|
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file.type / xk_file_type.value 过滤)
|
||||||
* 例如图片:[1];文件:[6];语音:[3]
|
* 例如图片:[0];视频:[1];文件:[6];语音:[3]
|
||||||
*/
|
*/
|
||||||
acceptTypes?: number[];
|
acceptTypes?: number[];
|
||||||
}>(),
|
}>(),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const props = withDefaults(
|
|||||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||||
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
||||||
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
|
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
|
||||||
*/
|
*/
|
||||||
acceptTypes?: number[];
|
acceptTypes?: number[];
|
||||||
}>(),
|
}>(),
|
||||||
@@ -217,9 +217,9 @@ function itemIcon(item: FileGalleryItem) {
|
|||||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 图片类型展示缩略图(xk_file_type:1=图片) */
|
/** 图片类型展示缩略图(xk_file.type:0=图片;兼容历史错误写入的 1) */
|
||||||
function isImageItem(item: FileGalleryItem) {
|
function isImageItem(item: FileGalleryItem) {
|
||||||
return Number(item.type) === 1 || Number(item.type) === 0;
|
return Number(item.type) === 0 || Number(item.type) === 1;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 门店多选搜索(对齐开方关键字气泡交互)
|
||||||
|
* - Teleport + fixed 锚定,避免搜索栏 overflow 裁切导致「气泡不见」
|
||||||
|
* - 多选:点选后不关闭面板,已选项从列表剔除并以 Tag 展示
|
||||||
|
* - Enter / ↑↓ 键盘选用,Esc 关闭
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||||
|
import { Empty, Input, Spin, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { searchStoreOption } from '#/views/system/store/api';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'StoreMultiSearch',
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StoreSearchItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type?: number;
|
||||||
|
shouzimu?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
value?: number[];
|
||||||
|
/** 0诊所 1药店;不传则诊所+药店 */
|
||||||
|
storeType?: 0 | 1;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
initialItems?: StoreSearchItem[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
value: () => [],
|
||||||
|
storeType: undefined,
|
||||||
|
placeholder: '输入名称或拼音首拼搜索',
|
||||||
|
disabled: false,
|
||||||
|
initialItems: () => [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
'update:value': [value: number[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const mValue = useVModel(props, 'value', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const panelRef = ref<HTMLElement | null>(null);
|
||||||
|
const listRef = ref<HTMLElement | null>(null);
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const showDropdown = ref(false);
|
||||||
|
const highlightIndex = ref(-1);
|
||||||
|
const options = ref<StoreSearchItem[]>([]);
|
||||||
|
const selectedMap = ref<Record<number, StoreSearchItem>>({});
|
||||||
|
const panelStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
const selectedIds = computed(() =>
|
||||||
|
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedItems = computed(() =>
|
||||||
|
selectedIds.value.map((id) => {
|
||||||
|
const cached = selectedMap.value[id];
|
||||||
|
return cached || { id, name: `门店${id}` };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 下拉中过滤掉已选,方便连续多选 */
|
||||||
|
const visibleOptions = computed(() =>
|
||||||
|
options.value.filter((item) => !selectedIds.value.includes(item.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
function mergeInitialItems(items: StoreSearchItem[]) {
|
||||||
|
if (!items?.length) return;
|
||||||
|
const next = { ...selectedMap.value };
|
||||||
|
for (const item of items) {
|
||||||
|
if (item?.id) next[item.id] = item;
|
||||||
|
}
|
||||||
|
selectedMap.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相对输入框视口坐标放置面板(与 EntryKeywordBubble 同思路)
|
||||||
|
*/
|
||||||
|
function updatePanelPlacement() {
|
||||||
|
const el = containerRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const gap = 4;
|
||||||
|
const pad = 8;
|
||||||
|
const spaceBelow = vh - rect.bottom - pad;
|
||||||
|
const spaceAbove = rect.top - pad;
|
||||||
|
const placeTop = spaceBelow < 200 && spaceAbove > spaceBelow;
|
||||||
|
const avail = Math.max(160, placeTop ? spaceAbove : spaceBelow);
|
||||||
|
const maxH = Math.min(320, avail);
|
||||||
|
const width = Math.min(Math.max(rect.width, 360), vw - pad * 2);
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + width > vw - pad) left = vw - pad - width;
|
||||||
|
if (left < pad) left = pad;
|
||||||
|
|
||||||
|
const base: Record<string, string> = {
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: '4000',
|
||||||
|
width: `${width}px`,
|
||||||
|
maxHeight: `${maxH}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
right: 'auto',
|
||||||
|
};
|
||||||
|
panelStyle.value = placeTop
|
||||||
|
? {
|
||||||
|
...base,
|
||||||
|
top: 'auto',
|
||||||
|
bottom: `${Math.max(pad, vh - rect.top + gap)}px`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...base,
|
||||||
|
top: `${rect.bottom + gap}px`,
|
||||||
|
bottom: 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPanel() {
|
||||||
|
showDropdown.value = true;
|
||||||
|
await nextTick();
|
||||||
|
updatePanelPlacement();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOptions() {
|
||||||
|
const keyword = searchKeyword.value.trim();
|
||||||
|
if (!keyword) {
|
||||||
|
options.value = [];
|
||||||
|
showDropdown.value = false;
|
||||||
|
highlightIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
await openPanel();
|
||||||
|
try {
|
||||||
|
const res = await searchStoreOption({
|
||||||
|
keyword,
|
||||||
|
...(props.storeType === 0 || props.storeType === 1
|
||||||
|
? { type: props.storeType }
|
||||||
|
: {}),
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
options.value = res?.items ?? [];
|
||||||
|
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
|
||||||
|
await nextTick();
|
||||||
|
updatePanelPlacement();
|
||||||
|
} catch {
|
||||||
|
options.value = [];
|
||||||
|
highlightIndex.value = -1;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
debouncedFetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
if (searchKeyword.value.trim() && options.value.length > 0) {
|
||||||
|
openPanel();
|
||||||
|
if (highlightIndex.value < 0 && visibleOptions.value.length > 0) {
|
||||||
|
highlightIndex.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多选:追加 ID,保留关键词与面板,不关闭气泡
|
||||||
|
*/
|
||||||
|
function selectOption(item: StoreSearchItem) {
|
||||||
|
if (selectedIds.value.includes(item.id)) return;
|
||||||
|
selectedMap.value = { ...selectedMap.value, [item.id]: item };
|
||||||
|
mValue.value = [...selectedIds.value, item.id];
|
||||||
|
// 高亮下一项(列表已剔除刚选的)
|
||||||
|
nextTick(() => {
|
||||||
|
if (visibleOptions.value.length === 0) {
|
||||||
|
highlightIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
highlightIndex.value = Math.min(
|
||||||
|
Math.max(highlightIndex.value, 0),
|
||||||
|
visibleOptions.value.length - 1,
|
||||||
|
);
|
||||||
|
updatePanelPlacement();
|
||||||
|
scrollHighlightIntoView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelected(id: number) {
|
||||||
|
mValue.value = selectedIds.value.filter((x) => x !== id);
|
||||||
|
const next = { ...selectedMap.value };
|
||||||
|
delete next[id];
|
||||||
|
selectedMap.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
showDropdown.value = false;
|
||||||
|
highlightIndex.value = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅改 list 的 scrollTop,表头固定不动 */
|
||||||
|
function scrollHighlightIntoView() {
|
||||||
|
nextTick(() => {
|
||||||
|
const listEl = listRef.value;
|
||||||
|
if (!listEl || highlightIndex.value < 0) return;
|
||||||
|
const el = listEl.querySelector(
|
||||||
|
`[data-store-opt-index="${highlightIndex.value}"]`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!el) return;
|
||||||
|
const listRect = listEl.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const deltaTop = elRect.top - listRect.top;
|
||||||
|
const deltaBottom = elRect.bottom - listRect.bottom;
|
||||||
|
if (deltaTop < 0) {
|
||||||
|
listEl.scrollTop += deltaTop;
|
||||||
|
} else if (deltaBottom > 0) {
|
||||||
|
listEl.scrollTop += deltaBottom;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!showDropdown.value || visibleOptions.value.length === 0) {
|
||||||
|
if (e.key === 'Escape') closePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const len = visibleOptions.value.length;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
// 头尾循环:最后一项再按下 → 回到第一项
|
||||||
|
highlightIndex.value =
|
||||||
|
highlightIndex.value < 0 ? 0 : (highlightIndex.value + 1) % len;
|
||||||
|
scrollHighlightIntoView();
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
// 头尾循环:第一项再按上 → 跳到最后一项
|
||||||
|
highlightIndex.value =
|
||||||
|
highlightIndex.value <= 0 ? len - 1 : highlightIndex.value - 1;
|
||||||
|
scrollHighlightIntoView();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
{
|
||||||
|
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
|
||||||
|
const item = visibleOptions.value[idx];
|
||||||
|
if (item) selectOption(item);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
closePanel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (!showDropdown.value) return;
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (containerRef.value?.contains(t) || panelRef.value?.contains(t)) return;
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWinChange() {
|
||||||
|
if (showDropdown.value) updatePanelPlacement();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('resize', onWinChange);
|
||||||
|
window.addEventListener('scroll', onWinChange, true);
|
||||||
|
mergeInitialItems(props.initialItems || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('resize', onWinChange);
|
||||||
|
window.removeEventListener('scroll', onWinChange, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.storeType,
|
||||||
|
() => {
|
||||||
|
options.value = [];
|
||||||
|
closePanel();
|
||||||
|
searchKeyword.value = '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialItems,
|
||||||
|
(items) => mergeInitialItems(items || []),
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(visibleOptions, (list) => {
|
||||||
|
if (!showDropdown.value) return;
|
||||||
|
if (list.length === 0) {
|
||||||
|
highlightIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (highlightIndex.value >= list.length) {
|
||||||
|
highlightIndex.value = list.length - 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="store-multi-search">
|
||||||
|
<Input
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
allow-clear
|
||||||
|
@input="handleInput"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<LoadingOutlined v-if="loading" class="text-muted-foreground" />
|
||||||
|
<SearchOutlined v-else class="text-muted-foreground" />
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showDropdown"
|
||||||
|
ref="panelRef"
|
||||||
|
class="store-multi-search__panel"
|
||||||
|
:style="panelStyle"
|
||||||
|
>
|
||||||
|
<div class="store-multi-search__hint">
|
||||||
|
<span>↑↓ 循环切换 · Enter 选用 · Esc 关闭 · 可连续多选</span>
|
||||||
|
<span class="store-multi-search__count">
|
||||||
|
剩 {{ visibleOptions.length }} 条可选
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="store-multi-search__body">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Empty
|
||||||
|
v-if="!loading && visibleOptions.length === 0"
|
||||||
|
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||||
|
description="暂无匹配门店(或已全部选中)"
|
||||||
|
class="py-3"
|
||||||
|
/>
|
||||||
|
<div ref="listRef" class="store-multi-search__list">
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in visibleOptions"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="store-multi-search__option"
|
||||||
|
:class="{ 'is-active': index === highlightIndex }"
|
||||||
|
:data-store-opt-index="index"
|
||||||
|
@mousedown.prevent="selectOption(item)"
|
||||||
|
>
|
||||||
|
<div class="store-multi-search__option-name">
|
||||||
|
{{ item.name }}【{{ item.id }}】
|
||||||
|
</div>
|
||||||
|
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
|
||||||
|
首拼:{{ item.shouzimu }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
|
||||||
|
<Tag
|
||||||
|
v-for="item in selectedItems"
|
||||||
|
:key="item.id"
|
||||||
|
closable
|
||||||
|
color="blue"
|
||||||
|
@close="() => removeSelected(item.id)"
|
||||||
|
>
|
||||||
|
{{ item.name }}【{{ item.id }}】
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.store-multi-search {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* Teleport 到 body,非 scoped,避免气泡被裁切/丢样式 */
|
||||||
|
.store-multi-search__panel {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px hsl(0 0% 0% / 0.12),
|
||||||
|
0 2px 8px hsl(0 0% 0% / 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头固定,仅 body/list 滚动 */
|
||||||
|
.store-multi-search__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__body .ant-spin-nested-loading,
|
||||||
|
.store-multi-search__body .ant-spin-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__option {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__option:hover,
|
||||||
|
.store-multi-search__option.is-active {
|
||||||
|
background: hsl(var(--accent) / 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__option-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__option-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 通用拖拽 + 粘贴上传:Dragger 点击/拖拽,下方外侧「开始监听粘贴」
|
||||||
|
* v-model 为已上传 URL 字符串数组;列表支持图片/PDF 预览(blob,避免直接下载)
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { preferences } from '@vben/preferences';
|
||||||
|
import { Button, Image, Modal, Upload, message } from 'ant-design-vue';
|
||||||
|
import type { UploadFile } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { Icon } from '#/components/icon';
|
||||||
|
import { uploadFile } from '#/api/core/upload';
|
||||||
|
import { uploadToOss } from '#/utils/oss-upload';
|
||||||
|
import {
|
||||||
|
matchAccept,
|
||||||
|
usePasteUploadListen,
|
||||||
|
} from '#/utils/use-paste-upload-listen';
|
||||||
|
|
||||||
|
defineOptions({ name: 'UploadDraggerPaste' });
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
status: 'uploading' | 'done' | 'error';
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string[];
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
maxCount?: number;
|
||||||
|
/** 单文件最大 MB */
|
||||||
|
maxSizeMb?: number;
|
||||||
|
tip?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: () => [],
|
||||||
|
accept: '.jpg,.jpeg,.png,.gif,.webp,.pdf,image/*,application/pdf',
|
||||||
|
multiple: true,
|
||||||
|
maxCount: 9,
|
||||||
|
maxSizeMb: 20,
|
||||||
|
tip: '点击或拖拽文件到此处上传',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const UploadDragger = Upload.Dragger;
|
||||||
|
const fileList = ref<FileItem[]>([]);
|
||||||
|
const uploading = ref(false);
|
||||||
|
|
||||||
|
/** 文件预览弹窗状态 */
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewTitle = ref('');
|
||||||
|
const previewIsImage = ref(false);
|
||||||
|
const previewImageUrl = ref('');
|
||||||
|
const previewPdfUrl = ref('');
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
|
||||||
|
const maxBytes = computed(() => props.maxSizeMb * 1024 * 1024);
|
||||||
|
|
||||||
|
function syncFromValue(urls: string[]) {
|
||||||
|
fileList.value = (urls || []).map((url) => ({
|
||||||
|
uid: url,
|
||||||
|
name: url.split('/').pop() || 'file',
|
||||||
|
status: 'done' as const,
|
||||||
|
url,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFromValue(props.modelValue);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
const current = fileList.value
|
||||||
|
.filter((f) => f.status === 'done')
|
||||||
|
.map((f) => f.url);
|
||||||
|
if (JSON.stringify(val || []) !== JSON.stringify(current)) {
|
||||||
|
syncFromValue(val || []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function emitUrls() {
|
||||||
|
const urls = fileList.value
|
||||||
|
.filter((f) => f.status === 'done' && f.url)
|
||||||
|
.map((f) => f.url);
|
||||||
|
emit('update:modelValue', urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageUrl(url: string) {
|
||||||
|
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokePdfBlob() {
|
||||||
|
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewPdfUrl.value);
|
||||||
|
}
|
||||||
|
previewPdfUrl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览已上传文件:图片用 Image;PDF fetch 成 blob 后 iframe,避免 OSS 直接下载
|
||||||
|
*/
|
||||||
|
async function previewItem(item: FileItem) {
|
||||||
|
if (!item.url) return;
|
||||||
|
revokePdfBlob();
|
||||||
|
previewTitle.value = item.name || '预览';
|
||||||
|
if (isImageUrl(item.url)) {
|
||||||
|
previewIsImage.value = true;
|
||||||
|
previewImageUrl.value = item.url;
|
||||||
|
previewVisible.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewIsImage.value = false;
|
||||||
|
previewLoading.value = true;
|
||||||
|
previewVisible.value = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(item.url);
|
||||||
|
if (!resp.ok) throw new Error('加载失败');
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const pdfBlob =
|
||||||
|
blob.type && blob.type !== 'application/octet-stream'
|
||||||
|
? blob
|
||||||
|
: new Blob([blob], { type: 'application/pdf' });
|
||||||
|
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
|
||||||
|
} catch {
|
||||||
|
message.error('预览失败');
|
||||||
|
previewVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewClose() {
|
||||||
|
previewVisible.value = false;
|
||||||
|
revokePdfBlob();
|
||||||
|
previewImageUrl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传单个文件并写入列表
|
||||||
|
*/
|
||||||
|
async function uploadOneFile(file: File) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||||
|
if (doneCount >= props.maxCount) {
|
||||||
|
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!matchAccept(file, props.accept)) {
|
||||||
|
message.warning('文件类型不符合要求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > maxBytes.value) {
|
||||||
|
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
fileList.value = [
|
||||||
|
...fileList.value,
|
||||||
|
{ uid, name: file.name, status: 'uploading', url: '' },
|
||||||
|
];
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||||
|
const res =
|
||||||
|
uploadMethod === 'direct'
|
||||||
|
? await uploadToOss({ file })
|
||||||
|
: await uploadFile({ file });
|
||||||
|
const url = res?.url || '';
|
||||||
|
if (!url) throw new Error('上传失败');
|
||||||
|
fileList.value = fileList.value.map((item) =>
|
||||||
|
item.uid === uid ? { ...item, status: 'done' as const, url } : item,
|
||||||
|
);
|
||||||
|
emitUrls();
|
||||||
|
} catch (e: any) {
|
||||||
|
fileList.value = fileList.value.filter((item) => item.uid !== uid);
|
||||||
|
message.error(e?.message || '上传失败');
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ant Design Upload customRequest */
|
||||||
|
const customRequest = async (options: any) => {
|
||||||
|
const { file, onSuccess, onError } = options;
|
||||||
|
try {
|
||||||
|
await uploadOneFile(file as File);
|
||||||
|
onSuccess?.({});
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function beforeUpload(file: File) {
|
||||||
|
if (props.disabled) return Upload.LIST_IGNORE;
|
||||||
|
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||||
|
if (doneCount >= props.maxCount) {
|
||||||
|
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (!matchAccept(file, props.accept)) {
|
||||||
|
message.warning('文件类型不符合要求');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size > maxBytes.value) {
|
||||||
|
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(file: UploadFile) {
|
||||||
|
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
|
||||||
|
emitUrls();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
listening: pasteListening,
|
||||||
|
toggle: togglePaste,
|
||||||
|
} = usePasteUploadListen({
|
||||||
|
get accept() {
|
||||||
|
return props.accept;
|
||||||
|
},
|
||||||
|
get maxSize() {
|
||||||
|
return maxBytes.value;
|
||||||
|
},
|
||||||
|
onFiles: async (files) => {
|
||||||
|
for (const file of files) {
|
||||||
|
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||||
|
if (doneCount >= props.maxCount) {
|
||||||
|
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await uploadOneFile(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTogglePaste() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
togglePaste();
|
||||||
|
if (pasteListening.value) {
|
||||||
|
message.success('已开启粘贴监听,可 Ctrl+V 粘贴文件/截图');
|
||||||
|
} else {
|
||||||
|
message.info('已停止粘贴监听');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDragger = computed(() => {
|
||||||
|
if (!props.multiple) {
|
||||||
|
return fileList.value.filter((f) => f.status === 'done').length === 0;
|
||||||
|
}
|
||||||
|
return fileList.value.filter((f) => f.status === 'done').length < props.maxCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
revokePdfBlob();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="upload-dragger-paste">
|
||||||
|
<UploadDragger
|
||||||
|
v-if="showDragger"
|
||||||
|
:file-list="[]"
|
||||||
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
|
:disabled="disabled || uploading"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:custom-request="customRequest"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:max-count="maxCount"
|
||||||
|
>
|
||||||
|
<p class="ant-upload-drag-icon">
|
||||||
|
<Icon icon="ant-design:inbox-outlined" class="text-4xl text-blue-500" />
|
||||||
|
</p>
|
||||||
|
<p class="ant-upload-text">{{ tip }}</p>
|
||||||
|
<p class="ant-upload-hint text-muted-foreground">
|
||||||
|
支持拖拽或点击选择,单文件不超过 {{ maxSizeMb }}MB
|
||||||
|
</p>
|
||||||
|
</UploadDragger>
|
||||||
|
|
||||||
|
<div v-if="fileList.length" class="mt-3 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in fileList"
|
||||||
|
:key="item.uid"
|
||||||
|
class="flex items-center gap-2 rounded border border-border bg-muted/20 px-3 py-2 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
item.status === 'uploading'
|
||||||
|
? 'ant-design:loading-outlined'
|
||||||
|
: item.status === 'error'
|
||||||
|
? 'ant-design:close-circle-outlined'
|
||||||
|
: 'ant-design:paper-clip-outlined'
|
||||||
|
"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="item.url"
|
||||||
|
type="button"
|
||||||
|
class="min-w-0 flex-1 truncate text-left text-primary"
|
||||||
|
@click="previewItem(item)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="min-w-0 flex-1 truncate text-muted-foreground">{{ item.name }}</span>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="handleRemove(item as any)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 粘贴监听按钮放在拖拽区域下方外侧 -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<Button
|
||||||
|
:type="pasteListening ? 'primary' : 'default'"
|
||||||
|
:danger="pasteListening"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="onTogglePaste"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
|
||||||
|
class="mr-1"
|
||||||
|
/>
|
||||||
|
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||||||
|
</Button>
|
||||||
|
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500 dark:text-orange-400">
|
||||||
|
监听中:请将文件或截图 Ctrl+V 粘贴
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
:open="previewVisible"
|
||||||
|
:title="previewTitle"
|
||||||
|
:footer="null"
|
||||||
|
width="80%"
|
||||||
|
destroy-on-close
|
||||||
|
@cancel="onPreviewClose"
|
||||||
|
>
|
||||||
|
<div v-if="previewLoading" class="py-16 text-center text-muted-foreground">加载中…</div>
|
||||||
|
<div v-else-if="previewIsImage" class="flex justify-center rounded border border-border bg-muted/25 p-2">
|
||||||
|
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-else-if="previewPdfUrl"
|
||||||
|
:src="previewPdfUrl"
|
||||||
|
class="h-[70vh] w-full rounded border border-border bg-background"
|
||||||
|
title="文件预览"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -24,12 +24,18 @@ interface Props {
|
|||||||
modelValue: string[];
|
modelValue: string[];
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
|
/**
|
||||||
|
* 素材库可选类型(透传 GalleryPickLink)
|
||||||
|
* xk_file.type:0=图片 1=视频;默认仅图片。不传会展示全部类型
|
||||||
|
*/
|
||||||
|
acceptTypes?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: () => [],
|
modelValue: () => [],
|
||||||
multiple: true,
|
multiple: true,
|
||||||
maxCount: 9,
|
maxCount: 9,
|
||||||
|
acceptTypes: () => [0],
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -345,6 +351,7 @@ onMounted(() => {
|
|||||||
v-if="showUploadButton"
|
v-if="showUploadButton"
|
||||||
:multiple="multiple"
|
:multiple="multiple"
|
||||||
:max-count="galleryRemainCount"
|
:max-count="galleryRemainCount"
|
||||||
|
:accept-types="acceptTypes"
|
||||||
@select="onGallerySelect"
|
@select="onGallerySelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 多图上传(picture-card)+ 下方「开始监听粘贴」支持截图/图片粘贴
|
||||||
|
*/
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { Modal, Upload } from 'ant-design-vue';
|
import { Button, Modal, Upload, message } from 'ant-design-vue';
|
||||||
import type { UploadFile } from 'ant-design-vue';
|
import type { UploadFile } from 'ant-design-vue';
|
||||||
|
|
||||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||||
@@ -10,9 +13,12 @@ import {
|
|||||||
beforeImageUpload,
|
beforeImageUpload,
|
||||||
resolveUploadFile,
|
resolveUploadFile,
|
||||||
} from '#/utils/use-image-upload-pending';
|
} from '#/utils/use-image-upload-pending';
|
||||||
|
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
|
||||||
import { preferences } from '@vben/preferences';
|
import { preferences } from '@vben/preferences';
|
||||||
import { Icon } from '#/components/icon';
|
import { Icon } from '#/components/icon';
|
||||||
|
|
||||||
|
defineOptions({ name: 'UploadImage' });
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
uid: string;
|
uid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,7 +31,8 @@ interface Props {
|
|||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
/**
|
/**
|
||||||
* 素材库可选类型(透传 GalleryPickLink),默认仅图片
|
* 素材库可选类型(透传 GalleryPickLink)
|
||||||
|
* xk_file.type:0=图片 1=视频,默认仅图片
|
||||||
*/
|
*/
|
||||||
acceptTypes?: number[];
|
acceptTypes?: number[];
|
||||||
}
|
}
|
||||||
@@ -34,13 +41,15 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
modelValue: () => [],
|
modelValue: () => [],
|
||||||
multiple: true,
|
multiple: true,
|
||||||
maxCount: 9,
|
maxCount: 9,
|
||||||
acceptTypes: () => [1],
|
acceptTypes: () => [0],
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string[]];
|
'update:modelValue': [value: string[]];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const IMAGE_ACCEPT = 'image/*,.jpg,.jpeg,.png,.gif,.webp,.bmp';
|
||||||
|
|
||||||
const fileList = ref<FileItem[]>([]);
|
const fileList = ref<FileItem[]>([]);
|
||||||
|
|
||||||
const initFileList = () => {
|
const initFileList = () => {
|
||||||
@@ -79,6 +88,41 @@ const updateModelValue = () => {
|
|||||||
emit('update:modelValue', urls);
|
emit('update:modelValue', urls);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传单个图片文件(点击上传与粘贴共用)
|
||||||
|
*/
|
||||||
|
async function uploadOneImage(file: File) {
|
||||||
|
const remain = props.maxCount - fileList.value.filter((f) => f.status === 'done').length;
|
||||||
|
if (remain <= 0) {
|
||||||
|
message.warning(`最多上传 ${props.maxCount} 张图片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actualFile = resolveUploadFile(file);
|
||||||
|
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const uploadingFile: FileItem = {
|
||||||
|
uid,
|
||||||
|
name: actualFile.name || file.name || 'paste.png',
|
||||||
|
status: 'uploading',
|
||||||
|
url: '',
|
||||||
|
};
|
||||||
|
fileList.value = [...fileList.value, uploadingFile];
|
||||||
|
try {
|
||||||
|
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||||
|
const res =
|
||||||
|
uploadMethod === 'direct'
|
||||||
|
? await uploadToOss({ file: actualFile })
|
||||||
|
: await uploadFile({ file: actualFile });
|
||||||
|
fileList.value = fileList.value.map((item) =>
|
||||||
|
item.uid === uid ? { ...item, status: 'done' as const, url: res.url } : item,
|
||||||
|
);
|
||||||
|
updateModelValue();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传失败', error);
|
||||||
|
fileList.value = fileList.value.filter((item) => item.uid !== uid);
|
||||||
|
message.error('上传失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const customRequest = async (options: any) => {
|
const customRequest = async (options: any) => {
|
||||||
const { file, onProgress, onSuccess, onError } = options;
|
const { file, onProgress, onSuccess, onError } = options;
|
||||||
const actualFile = resolveUploadFile(file as File);
|
const actualFile = resolveUploadFile(file as File);
|
||||||
@@ -160,6 +204,30 @@ const showUploadButton = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
|
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
|
||||||
|
|
||||||
|
const { listening: pasteListening, toggle: togglePaste } = usePasteUploadListen({
|
||||||
|
accept: IMAGE_ACCEPT,
|
||||||
|
maxSize: 20 * 1024 * 1024,
|
||||||
|
onFiles: async (files) => {
|
||||||
|
for (const file of files) {
|
||||||
|
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||||
|
if (doneCount >= props.maxCount) {
|
||||||
|
message.warning(`最多上传 ${props.maxCount} 张图片`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await uploadOneImage(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTogglePaste() {
|
||||||
|
togglePaste();
|
||||||
|
if (pasteListening.value) {
|
||||||
|
message.success('已开启粘贴监听,可 Ctrl+V 粘贴图片/截图');
|
||||||
|
} else {
|
||||||
|
message.info('已停止粘贴监听');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -193,6 +261,25 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
|
|||||||
@select="onGallerySelect"
|
@select="onGallerySelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 粘贴监听放在上传区下方外侧 -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
:type="pasteListening ? 'primary' : 'default'"
|
||||||
|
:danger="pasteListening"
|
||||||
|
@click="onTogglePaste"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
|
||||||
|
class="mr-1"
|
||||||
|
/>
|
||||||
|
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||||||
|
</Button>
|
||||||
|
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
|
||||||
|
监听中:Ctrl+V 粘贴图片
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
|
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
|
||||||
<img alt="预览图片" style="width: 100%" :src="previewImage" />
|
<img alt="预览图片" style="width: 100%" :src="previewImage" />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export type CustomComponentType =
|
|||||||
| 'ApiSelect'
|
| 'ApiSelect'
|
||||||
| 'ApiTreeSelect'
|
| 'ApiTreeSelect'
|
||||||
| 'IconPicker'
|
| 'IconPicker'
|
||||||
|
| 'StoreMultiSearch'
|
||||||
| 'WarehouseAdminDrugSearch';
|
| 'WarehouseAdminDrugSearch';
|
||||||
|
|||||||
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
AI 加载动画:处方=药丸;病历=病历本
|
||||||
|
type + text 由调用方传入;嵌入抽屉时默认不显示主题切换
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="loading-wrapper"
|
||||||
|
:class="{ 'loading-wrapper--dark': isDark }"
|
||||||
|
:style="{ width: '100%', height: '100%' }"
|
||||||
|
>
|
||||||
|
<canvas ref="canvasRef" class="loading-canvas"></canvas>
|
||||||
|
<button
|
||||||
|
v-if="showToggle"
|
||||||
|
class="theme-toggle-btn"
|
||||||
|
:title="isDark ? '切换到亮色模式' : '切换到暗色模式'"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
<span class="theme-icon">
|
||||||
|
<svg
|
||||||
|
v-if="!isDark"
|
||||||
|
class="sun"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" />
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="moon"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<!-- <span class="theme-label">{{ isDark ? '亮色' : '暗色' }}</span>-->
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* AI 生成加载动画
|
||||||
|
* - type=prescription:中心画胶囊药丸
|
||||||
|
* - type=medical_record:中心画病历本
|
||||||
|
* - text:底部文案(可动态传)
|
||||||
|
*/
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
export type AiLoadingType = 'prescription' | 'medical_record';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 动画类型:处方 / 病历 */
|
||||||
|
type?: AiLoadingType;
|
||||||
|
/** 底部文案 */
|
||||||
|
text?: string;
|
||||||
|
/** 是否显示主题切换(嵌入业务抽屉建议关闭) */
|
||||||
|
showToggle?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
type: 'prescription',
|
||||||
|
text: '',
|
||||||
|
showToggle: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 未传文案时按 type 给默认值 */
|
||||||
|
const displayText = computed(() => {
|
||||||
|
const t = String(props.text || '').trim();
|
||||||
|
if (t) return t;
|
||||||
|
return props.type === 'medical_record'
|
||||||
|
? 'AI正在生成病历'
|
||||||
|
: 'AI正在生成处方';
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const isDark = ref(false);
|
||||||
|
/** 画布循环里读取最新文案/类型,避免闭包过期 */
|
||||||
|
const liveText = ref(displayText.value);
|
||||||
|
const liveType = ref<AiLoadingType>(props.type);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
displayText,
|
||||||
|
(v) => {
|
||||||
|
liveText.value = v;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.type,
|
||||||
|
(v) => {
|
||||||
|
liveType.value = v || 'prescription';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function detectAppDark(): boolean {
|
||||||
|
return (
|
||||||
|
document.documentElement.classList.contains('dark') ||
|
||||||
|
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||||
|
document.documentElement.getAttribute('class')?.includes('dark') === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncThemeFromApp() {
|
||||||
|
if (localStorage.getItem('prescription-loading-theme')) {
|
||||||
|
isDark.value = localStorage.getItem('prescription-loading-theme') === 'dark';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isDark.value = detectAppDark();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
isDark.value = !isDark.value;
|
||||||
|
localStorage.setItem(
|
||||||
|
'prescription-loading-theme',
|
||||||
|
isDark.value ? 'dark' : 'light',
|
||||||
|
);
|
||||||
|
resetParticles?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
life: number;
|
||||||
|
maxLife: number;
|
||||||
|
size: number;
|
||||||
|
color: { r: number; g: number; b: number };
|
||||||
|
type: 'circle' | 'cross';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeColors {
|
||||||
|
textColor: string;
|
||||||
|
ringColor: string;
|
||||||
|
glowInner: string;
|
||||||
|
glowMiddle: string;
|
||||||
|
glowOuter: string;
|
||||||
|
particleBright: number;
|
||||||
|
iconShadow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationId = 0;
|
||||||
|
let resetParticles: (() => void) | null = null;
|
||||||
|
let themeObserver: MutationObserver | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let removeWindowResize: (() => void) | null = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
syncThemeFromApp();
|
||||||
|
if (!containerRef.value || !canvasRef.value) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const container = containerRef.value;
|
||||||
|
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
let cssWidth = 0;
|
||||||
|
let cssHeight = 0;
|
||||||
|
let prevW = 0;
|
||||||
|
let prevH = 0;
|
||||||
|
let startTime: number | null = null;
|
||||||
|
let lastTime: number | null = null;
|
||||||
|
let particles: Particle[] = [];
|
||||||
|
const MAX_PARTICLES = 28;
|
||||||
|
|
||||||
|
resetParticles = () => {
|
||||||
|
particles = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateSize() {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
cssWidth = rect.width || 300;
|
||||||
|
cssHeight = rect.height || 280;
|
||||||
|
const pw = Math.round(cssWidth * dpr);
|
||||||
|
const ph = Math.round(cssHeight * dpr);
|
||||||
|
if (canvas.width !== pw || canvas.height !== ph) {
|
||||||
|
canvas.width = pw;
|
||||||
|
canvas.height = ph;
|
||||||
|
if (
|
||||||
|
prevW &&
|
||||||
|
(Math.abs(cssWidth - prevW) > 2 || Math.abs(cssHeight - prevH) > 2)
|
||||||
|
) {
|
||||||
|
particles = [];
|
||||||
|
}
|
||||||
|
prevW = cssWidth;
|
||||||
|
prevH = cssHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticle(cx: number, cy: number, s: number): Particle {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const distance = s * (0.15 + Math.random() * 0.45);
|
||||||
|
const speed = s * (0.03 + Math.random() * 0.08);
|
||||||
|
const colors = [
|
||||||
|
{ r: 61, g: 214, b: 208 },
|
||||||
|
{ r: 74, g: 195, b: 190 },
|
||||||
|
{ r: 93, g: 173, b: 226 },
|
||||||
|
{ r: 46, g: 180, b: 170 },
|
||||||
|
{ r: 100, g: 200, b: 215 },
|
||||||
|
];
|
||||||
|
const color = colors[Math.floor(Math.random() * colors.length)]!;
|
||||||
|
return {
|
||||||
|
x: cx + Math.cos(angle) * distance,
|
||||||
|
y: cy + Math.sin(angle) * distance,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
life: 1,
|
||||||
|
maxLife: 1.8 + Math.random() * 2.5,
|
||||||
|
size: 1.2 + Math.random() * 2.8,
|
||||||
|
color,
|
||||||
|
type: Math.random() < 0.25 ? 'cross' : 'circle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParticles(dt: number, cx: number, cy: number, s: number) {
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = particles[i]!;
|
||||||
|
p.life -= dt / p.maxLife;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
p.vx *= 0.997;
|
||||||
|
p.vy *= 0.997;
|
||||||
|
if (p.life <= 0) particles.splice(i, 1);
|
||||||
|
}
|
||||||
|
while (particles.length < MAX_PARTICLES) {
|
||||||
|
const p = createParticle(cx, cy, s);
|
||||||
|
const ang = Math.random() * Math.PI * 2;
|
||||||
|
p.x = cx + Math.cos(ang) * s * 0.12 * Math.random();
|
||||||
|
p.y = cy + Math.sin(ang) * s * 0.12 * Math.random();
|
||||||
|
particles.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticle(
|
||||||
|
c: CanvasRenderingContext2D,
|
||||||
|
p: Particle,
|
||||||
|
bright: number,
|
||||||
|
) {
|
||||||
|
const alpha = p.life;
|
||||||
|
const { r, g, b } = p.color;
|
||||||
|
const size = p.size * (0.7 + 0.3 * p.life);
|
||||||
|
const br = Math.min(255, Math.round(r * bright));
|
||||||
|
const bg = Math.min(255, Math.round(g * bright));
|
||||||
|
const bb = Math.min(255, Math.round(b * bright));
|
||||||
|
c.save();
|
||||||
|
c.globalAlpha = alpha;
|
||||||
|
if (p.type === 'cross') {
|
||||||
|
const len = size * 1.8;
|
||||||
|
const w = size * 0.55;
|
||||||
|
c.strokeStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||||
|
c.lineWidth = w;
|
||||||
|
c.lineCap = 'round';
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(p.x - len, p.y);
|
||||||
|
c.lineTo(p.x + len, p.y);
|
||||||
|
c.stroke();
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(p.x, p.y - len);
|
||||||
|
c.lineTo(p.x, p.y + len);
|
||||||
|
c.stroke();
|
||||||
|
c.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(p.x, p.y, size * 0.35, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
} else {
|
||||||
|
c.shadowColor = `rgba(${br},${bg},${bb},${alpha * 0.7})`;
|
||||||
|
c.shadowBlur = size * 2.5;
|
||||||
|
c.fillStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
c.shadowBlur = 0;
|
||||||
|
c.fillStyle = `rgba(255,255,255,${alpha * 0.6})`;
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(p.x, p.y, size * 0.4, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
}
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处方药丸(处方) */
|
||||||
|
function drawPill(
|
||||||
|
c: CanvasRenderingContext2D,
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
pw: number,
|
||||||
|
ph: number,
|
||||||
|
floatOff: number,
|
||||||
|
pulse: number,
|
||||||
|
shadowCol: string,
|
||||||
|
) {
|
||||||
|
c.save();
|
||||||
|
const cx = px + pw / 2;
|
||||||
|
const cy = py + ph / 2;
|
||||||
|
const hw = (pw / 2) * pulse;
|
||||||
|
const hh = (ph / 2) * pulse;
|
||||||
|
const adjY = cy + floatOff;
|
||||||
|
const dx = cx - hw;
|
||||||
|
const dy = adjY - hh;
|
||||||
|
const dw = hw * 2;
|
||||||
|
const dh = hh * 2;
|
||||||
|
const rad = hh;
|
||||||
|
const path = new Path2D();
|
||||||
|
path.roundRect(dx, dy, dw, dh, rad);
|
||||||
|
|
||||||
|
c.shadowColor = shadowCol;
|
||||||
|
c.shadowBlur = hh * 0.7;
|
||||||
|
c.shadowOffsetY = hh * 0.25;
|
||||||
|
c.fillStyle = '#ffffff';
|
||||||
|
c.fill(path);
|
||||||
|
c.shadowColor = 'transparent';
|
||||||
|
c.shadowBlur = 0;
|
||||||
|
c.shadowOffsetY = 0;
|
||||||
|
|
||||||
|
c.save();
|
||||||
|
c.clip(path);
|
||||||
|
const leftGrad = c.createLinearGradient(dx, 0, dx + dw * 0.5, 0);
|
||||||
|
leftGrad.addColorStop(0, '#FDFBF7');
|
||||||
|
leftGrad.addColorStop(0.7, '#F5F1EA');
|
||||||
|
leftGrad.addColorStop(1, '#EDE8DF');
|
||||||
|
c.fillStyle = leftGrad;
|
||||||
|
c.fillRect(dx, dy, dw * 0.5, dh);
|
||||||
|
|
||||||
|
const rightGrad = c.createLinearGradient(dx + dw * 0.5, 0, dx + dw, 0);
|
||||||
|
rightGrad.addColorStop(0, '#5DD9D1');
|
||||||
|
rightGrad.addColorStop(0.5, '#3DD6D0');
|
||||||
|
rightGrad.addColorStop(1, '#2CB8B0');
|
||||||
|
c.fillStyle = rightGrad;
|
||||||
|
c.fillRect(dx + dw * 0.5, dy, dw * 0.5, dh);
|
||||||
|
|
||||||
|
c.strokeStyle = 'rgba(180,180,180,0.5)';
|
||||||
|
c.lineWidth = Math.max(0.6, hh * 0.06);
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(dx + dw * 0.5, dy + 1);
|
||||||
|
c.lineTo(dx + dw * 0.5, dy + dh - 1);
|
||||||
|
c.stroke();
|
||||||
|
|
||||||
|
const hy = dy + dh * 0.15;
|
||||||
|
const hh2 = dh * 0.38;
|
||||||
|
const hp = new Path2D();
|
||||||
|
hp.roundRect(dx + dw * 0.06, hy, dw * 0.88, hh2, hh2 / 2);
|
||||||
|
c.fillStyle = 'rgba(255,255,255,0.45)';
|
||||||
|
c.fill(hp);
|
||||||
|
c.restore();
|
||||||
|
|
||||||
|
c.strokeStyle = 'rgba(200,200,200,0.5)';
|
||||||
|
c.lineWidth = Math.max(0.8, hh * 0.08);
|
||||||
|
c.stroke(path);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 病历本(病历) */
|
||||||
|
function drawMedicalRecord(
|
||||||
|
c: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
scale: number,
|
||||||
|
floatOff: number,
|
||||||
|
pulse: number,
|
||||||
|
shadowCol: string,
|
||||||
|
) {
|
||||||
|
c.save();
|
||||||
|
const w = scale * 0.28 * pulse;
|
||||||
|
const h = scale * 0.34 * pulse;
|
||||||
|
const x = cx - w / 2;
|
||||||
|
const y = cy - h / 2 + floatOff;
|
||||||
|
const r = Math.max(4, scale * 0.018);
|
||||||
|
const path = new Path2D();
|
||||||
|
path.roundRect(x, y, w, h, r);
|
||||||
|
|
||||||
|
c.shadowColor = shadowCol;
|
||||||
|
c.shadowBlur = scale * 0.035;
|
||||||
|
c.shadowOffsetY = scale * 0.012;
|
||||||
|
c.fillStyle = '#ffffff';
|
||||||
|
c.fill(path);
|
||||||
|
c.shadowColor = 'transparent';
|
||||||
|
c.shadowBlur = 0;
|
||||||
|
c.shadowOffsetY = 0;
|
||||||
|
|
||||||
|
// 左侧装订条
|
||||||
|
c.fillStyle = '#3DD6D0';
|
||||||
|
c.fillRect(x, y + r * 0.2, w * 0.12, h - r * 0.4);
|
||||||
|
|
||||||
|
// 横线(像病历行)
|
||||||
|
c.strokeStyle = 'rgba(61,214,208,0.35)';
|
||||||
|
c.lineWidth = Math.max(1, scale * 0.004);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const ly = y + h * (0.32 + i * 0.14);
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(x + w * 0.22, ly);
|
||||||
|
c.lineTo(x + w * 0.88, ly);
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
// 顶部标题条
|
||||||
|
c.fillStyle = 'rgba(61,214,208,0.2)';
|
||||||
|
c.beginPath();
|
||||||
|
c.roundRect(x + w * 0.22, y + h * 0.12, w * 0.5, h * 0.08, 2);
|
||||||
|
c.fill();
|
||||||
|
|
||||||
|
c.strokeStyle = 'rgba(180,200,200,0.55)';
|
||||||
|
c.lineWidth = Math.max(0.8, scale * 0.005);
|
||||||
|
c.stroke(path);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCross(
|
||||||
|
c: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
size: number,
|
||||||
|
alpha: number,
|
||||||
|
rot: number,
|
||||||
|
) {
|
||||||
|
c.save();
|
||||||
|
c.translate(x, y);
|
||||||
|
c.rotate(rot);
|
||||||
|
c.globalAlpha = alpha;
|
||||||
|
const len = size;
|
||||||
|
const w = size * 0.3;
|
||||||
|
c.strokeStyle = '#3DD6D0';
|
||||||
|
c.lineWidth = w;
|
||||||
|
c.lineCap = 'round';
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(-len, 0);
|
||||||
|
c.lineTo(len, 0);
|
||||||
|
c.stroke();
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(0, -len);
|
||||||
|
c.lineTo(0, len);
|
||||||
|
c.stroke();
|
||||||
|
c.fillStyle = '#ffffff';
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(0, 0, size * 0.25, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTheme(): ThemeColors {
|
||||||
|
const dark = isDark.value;
|
||||||
|
return {
|
||||||
|
textColor: dark ? '#d1d5db' : '#4a5568',
|
||||||
|
ringColor: dark ? 'rgba(255,255,255,0.25)' : 'rgba(180,195,200,0.45)',
|
||||||
|
glowInner: dark ? 'rgba(61,214,208,0.15)' : 'rgba(61,214,208,0.13)',
|
||||||
|
glowMiddle: dark ? 'rgba(61,214,208,0.05)' : 'rgba(61,214,208,0.04)',
|
||||||
|
glowOuter: 'rgba(61,214,208,0)',
|
||||||
|
particleBright: dark ? 1.4 : 1.0,
|
||||||
|
iconShadow: dark ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.12)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(timestamp: number) {
|
||||||
|
updateSize();
|
||||||
|
if (cssWidth <= 0 || cssHeight <= 0) {
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startTime === null) {
|
||||||
|
startTime = timestamp;
|
||||||
|
lastTime = timestamp;
|
||||||
|
}
|
||||||
|
const elapsed = (timestamp - startTime) / 1000;
|
||||||
|
let dt = (timestamp - (lastTime ?? timestamp)) / 1000;
|
||||||
|
if (dt <= 0) dt = 0.016;
|
||||||
|
if (dt > 0.2) dt = 0.2;
|
||||||
|
lastTime = timestamp;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const cx = cssWidth / 2;
|
||||||
|
const cy = cssHeight / 2 - cssHeight * 0.04;
|
||||||
|
const scale = Math.min(cssWidth, cssHeight);
|
||||||
|
const orbitR = scale * 0.26;
|
||||||
|
const fontSize = Math.max(12, scale * 0.055);
|
||||||
|
const textY = cy + orbitR + fontSize * 2.4;
|
||||||
|
const text = liveText.value;
|
||||||
|
const animType = liveType.value;
|
||||||
|
|
||||||
|
const dashOff = elapsed * scale * 0.025;
|
||||||
|
const arcRot = elapsed * 3.5;
|
||||||
|
const arcLen = (100 * Math.PI) / 180;
|
||||||
|
const floatOff = Math.sin(elapsed * 2.5) * scale * 0.025;
|
||||||
|
const pulseS = 1 + Math.sin(elapsed * 3.7) * 0.04;
|
||||||
|
const textAlpha = 0.55 + 0.45 * Math.sin(elapsed * 1.6);
|
||||||
|
const theme = getTheme();
|
||||||
|
|
||||||
|
const grad = ctx.createRadialGradient(
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
orbitR * 0.15,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
orbitR * 1.6,
|
||||||
|
);
|
||||||
|
grad.addColorStop(0, theme.glowInner);
|
||||||
|
grad.addColorStop(0.5, theme.glowMiddle);
|
||||||
|
grad.addColorStop(1, theme.glowOuter);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.setLineDash([scale * 0.018, scale * 0.04]);
|
||||||
|
ctx.lineDashOffset = -dashOff;
|
||||||
|
ctx.strokeStyle = theme.ringColor;
|
||||||
|
ctx.lineWidth = Math.max(1, scale * 0.006);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, orbitR, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const arcS = arcRot;
|
||||||
|
const arcE = arcRot + arcLen;
|
||||||
|
const fx = cx + orbitR * Math.cos(arcE);
|
||||||
|
const fy = cy + orbitR * Math.sin(arcE);
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = 'rgba(61,214,208,0.8)';
|
||||||
|
ctx.shadowBlur = scale * 0.04;
|
||||||
|
ctx.strokeStyle = '#3DD6D0';
|
||||||
|
ctx.lineWidth = Math.max(2, scale * 0.014);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, orbitR, arcS, arcE);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = 'rgba(255,255,255,0.9)';
|
||||||
|
ctx.shadowBlur = scale * 0.03;
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(fx, fy, Math.max(2.5, scale * 0.014), 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 中心图标:按 type 切换
|
||||||
|
if (animType === 'medical_record') {
|
||||||
|
drawMedicalRecord(ctx, cx, cy, scale, floatOff, pulseS, theme.iconShadow);
|
||||||
|
} else {
|
||||||
|
const pillW = scale * 0.34;
|
||||||
|
const pillH = scale * 0.1;
|
||||||
|
drawPill(
|
||||||
|
ctx,
|
||||||
|
cx - pillW / 2,
|
||||||
|
cy - pillH / 2,
|
||||||
|
pillW,
|
||||||
|
pillH,
|
||||||
|
floatOff,
|
||||||
|
pulseS,
|
||||||
|
theme.iconShadow,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const ang = elapsed * 1.8 + (i * Math.PI * 2) / 3;
|
||||||
|
const crossOrbitR = orbitR * 1.02;
|
||||||
|
const rx = cx + crossOrbitR * Math.cos(ang);
|
||||||
|
const ry = cy + crossOrbitR * Math.sin(ang);
|
||||||
|
const rs = Math.max(4, scale * 0.022);
|
||||||
|
const ra = 0.4 + 0.35 * Math.sin(elapsed * 3.2 + i * 1.7);
|
||||||
|
drawCross(ctx, rx, ry, rs, ra, elapsed * 0.9 + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParticles(dt, cx, cy, scale);
|
||||||
|
for (const p of particles) {
|
||||||
|
drawParticle(ctx, p, theme.particleBright);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = textAlpha;
|
||||||
|
ctx.fillStyle = theme.textColor;
|
||||||
|
ctx.font = `${fontSize}px "PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.shadowColor = 'rgba(61,214,208,0.25)';
|
||||||
|
ctx.shadowBlur = fontSize * 0.6;
|
||||||
|
ctx.fillText(text, cx, textY);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const charW = fontSize * 0.55;
|
||||||
|
const dotsStartX = cx + (text.length / 2) * charW + fontSize * 0.5;
|
||||||
|
const dotR = Math.max(2, fontSize * 0.16);
|
||||||
|
const dotSpacing = fontSize * 0.7;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const phase = (elapsed * 2.8 + i * 1.0) % 3;
|
||||||
|
let brightness = phase < 1 ? phase : phase < 2 ? 2 - phase : 0;
|
||||||
|
brightness = brightness * brightness * (3 - 2 * brightness);
|
||||||
|
const alpha = 0.2 + brightness * 0.75;
|
||||||
|
const dx = dotsStartX + i * dotSpacing * 2.2;
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = `rgba(61,214,208,${alpha * 0.8})`;
|
||||||
|
ctx.shadowBlur = dotR * 3;
|
||||||
|
ctx.fillStyle = `rgba(61,214,208,${alpha})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dx, textY, dotR, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
resizeObserver = new ResizeObserver(() => updateSize());
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
} else {
|
||||||
|
const onResize = () => updateSize();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
removeWindowResize = () => window.removeEventListener('resize', onResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跟随应用 dark class(未手动锁定主题时)
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
if (!localStorage.getItem('prescription-loading-theme')) {
|
||||||
|
syncThemeFromApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'data-theme'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
removeWindowResize?.();
|
||||||
|
removeWindowResize = null;
|
||||||
|
themeObserver?.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
resetParticles = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
//background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.loading-wrapper--dark {
|
||||||
|
//background: #1e2227;
|
||||||
|
}
|
||||||
|
.loading-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.theme-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4a5568;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.loading-wrapper--dark .theme-toggle-btn {
|
||||||
|
background: rgba(30, 34, 39, 0.9);
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.theme-toggle-btn:hover {
|
||||||
|
border-color: #3dd6d0;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
.theme-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -105,7 +105,9 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
|||||||
{{ doctorInfo.doctor.name }} {{ doctorInfo.doctor.title.name }}
|
{{ doctorInfo.doctor.name }} {{ doctorInfo.doctor.title.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="qrAddress">
|
<div class="qrAddress">
|
||||||
<span class="addressTitle">诊所地址:</span>{{ previewAddress }}
|
<span class="addressTitle">{{
|
||||||
|
Number(doctorInfo?.store?.type) === 1 ? '药店地址:' : '诊所地址:'
|
||||||
|
}}</span>{{ previewAddress }}
|
||||||
</div>
|
</div>
|
||||||
<Image :preview="false" :src="doctorInfo.qr_code" class="mt-3" height="30" width="30" />
|
<Image :preview="false" :src="doctorInfo.qr_code" class="mt-3" height="30" width="30" />
|
||||||
<h3 class="border-l-4 border-blue-500 pl-3 text-lg font-semibold">
|
<h3 class="border-l-4 border-blue-500 pl-3 text-lg font-semibold">
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
/**
|
||||||
|
* 诊所/药店二维码预览弹窗
|
||||||
|
* 按门店 type(0 诊所 / 1 药店)切换「诊所|药店」文案,列表页与门店卡片共用
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
@@ -11,6 +15,13 @@ const htmlToImage = ref('');
|
|||||||
const url = ref('');
|
const url = ref('');
|
||||||
const previewTitle = ref('二维码');
|
const previewTitle = ref('二维码');
|
||||||
const previewAddress = ref('');
|
const previewAddress = ref('');
|
||||||
|
/** 门店类型:0=诊所 1=药店,未传时按诊所示 */
|
||||||
|
const storeType = ref(0);
|
||||||
|
|
||||||
|
const isPharmacy = computed(() => Number(storeType.value) === 1);
|
||||||
|
const kindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
|
||||||
|
const addressLabel = computed(() => `${kindLabel.value}地址:`);
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
fullscreenButton: false,
|
fullscreenButton: false,
|
||||||
footer: false,
|
footer: false,
|
||||||
@@ -28,21 +39,28 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
},
|
},
|
||||||
onOpenChange(isOpen: boolean) {
|
onOpenChange(isOpen: boolean) {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const { values, title, address } =
|
const { values, title, address, type, store_type } =
|
||||||
modalApi.getData<Record<string, any>>();
|
modalApi.getData<Record<string, any>>() || {};
|
||||||
if (values) {
|
if (values) {
|
||||||
url.value = values;
|
url.value = values;
|
||||||
qrCodeUrl.value = values;
|
qrCodeUrl.value = values;
|
||||||
previewTitle.value = title;
|
previewTitle.value = title;
|
||||||
previewAddress.value = address;
|
previewAddress.value = address;
|
||||||
|
// 兼容 type / store_type 两种传参
|
||||||
|
storeType.value = Number(type ?? store_type ?? 0);
|
||||||
|
modalApi.setState({
|
||||||
|
confirmText: `保存${kindLabel.value}卡片图片`,
|
||||||
|
cancelText: `保存${kindLabel.value}二维码图片`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载二维码
|
* 下载二维码卡片或纯二维码图片
|
||||||
* @param name
|
* @param name 文件名后缀
|
||||||
* @param isQr
|
* @param isQr true=只截二维码区域,false=截整张卡片
|
||||||
*/
|
*/
|
||||||
const downloadQRCode = async (name: string, isQr = false) => {
|
const downloadQRCode = async (name: string, isQr = false) => {
|
||||||
const element = isQr ? qrCodeUrl.value : htmlToImage.value;
|
const element = isQr ? qrCodeUrl.value : htmlToImage.value;
|
||||||
@@ -51,25 +69,24 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
|||||||
allowTaint: true,
|
allowTaint: true,
|
||||||
logging: false,
|
logging: false,
|
||||||
}).then((canvas) => {
|
}).then((canvas) => {
|
||||||
// 创建a标签下载
|
const link = document.createElement('a');
|
||||||
const link = document.createElement('a'); // 创建a标签
|
link.href = canvas.toDataURL();
|
||||||
link.href = canvas.toDataURL(); // 是canvas对象的一种方法,用于将canvas对象转换为base64位编码
|
link.setAttribute('download', `${previewTitle.value}${name}.png`);
|
||||||
link.setAttribute('download', `${previewTitle.value}${name}.png`); // 利用了a标签的download 来下载 canvas图片
|
link.style.display = 'none';
|
||||||
link.style.display = 'none'; // 将图片隐藏起来
|
document.body.append(link);
|
||||||
document.body.append(link); // 插入到其中
|
|
||||||
link.click();
|
link.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Modal :title="`${previewTitle}二维码`" class="w-[50%]">
|
<Modal :title="`${previewTitle}二维码`">
|
||||||
<div class="qrBox" style="text-align: center">
|
<div class="qrBox" style="text-align: center">
|
||||||
<div ref="htmlToImage" class="qrModal">
|
<div ref="htmlToImage" class="qrModal">
|
||||||
<div id="qrcode" ref="qrCodeUrl" class="qrcode">
|
<div id="qrcode" ref="qrCodeUrl" class="qrcode">
|
||||||
<div class="qrTitle">{{ previewTitle }}</div>
|
<div class="qrTitle">{{ previewTitle }}</div>
|
||||||
<Image :preview="false" :src="url" height="30" width="30" />
|
<Image :preview="false" :src="url" height="30" width="30" />
|
||||||
<div class="qrAddress">
|
<div class="qrAddress">
|
||||||
<span class="addressTitle">诊所地址:</span>{{ previewAddress }}
|
<span class="addressTitle">{{ addressLabel }}</span>{{ previewAddress }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface FormFieldSchema {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
/** select / radio 类型的选项列表 */
|
/** select / radio 类型的选项列表 */
|
||||||
options?: { label: string; value: any }[];
|
options?: { label: string; value: any }[];
|
||||||
/** 图片字段:限定素材库文件类型(如 [1] 只显示图片) */
|
/** 图片字段:限定素材库文件类型(如 [0] 只显示图片) */
|
||||||
acceptTypes?: number[];
|
acceptTypes?: number[];
|
||||||
/** 最大长度 */
|
/** 最大长度 */
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 处方溯源展示内容(对齐小程序药师溯源:就诊→开具→审核)
|
||||||
|
* 由审方/业务处方两个 Modal 共用,避免双份漂移
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Timeline, TimelineItem } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
MedicineBoxOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: Record<string, any> | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formatTime = (timestamp: unknown) => {
|
||||||
|
if (!timestamp) return '--';
|
||||||
|
if (typeof timestamp === 'string' && timestamp.includes('-')) return timestamp;
|
||||||
|
const n = Number(timestamp);
|
||||||
|
if (!n) return '--';
|
||||||
|
return new Date(n * 1000).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const prescriptionTypeMap: Record<number, string> = {
|
||||||
|
1: '中药处方',
|
||||||
|
2: '西药处方',
|
||||||
|
3: '保健食品',
|
||||||
|
5: '产品服务包',
|
||||||
|
6: '非药品',
|
||||||
|
7: '医疗器械',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMap: Record<number, string> = {
|
||||||
|
0: '待审核',
|
||||||
|
1: '已通过',
|
||||||
|
2: '已拒绝',
|
||||||
|
3: '已过期',
|
||||||
|
};
|
||||||
|
|
||||||
|
const prescriptionTypeText = computed(() => {
|
||||||
|
if (!props.data) return '--';
|
||||||
|
return prescriptionTypeMap[props.data.prescription_type] || '未知';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (!props.data) return '--';
|
||||||
|
return statusMap[props.data.status] || '未知';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
if (!props.data) return 'gray';
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
0: 'orange',
|
||||||
|
1: 'green',
|
||||||
|
2: 'red',
|
||||||
|
3: 'gray',
|
||||||
|
};
|
||||||
|
return map[props.data.status] || 'gray';
|
||||||
|
});
|
||||||
|
|
||||||
|
const expireTime = computed(() =>
|
||||||
|
props.data ? formatTime(props.data.auto_expire_time) : '--',
|
||||||
|
);
|
||||||
|
|
||||||
|
const patientSexText = computed(() => {
|
||||||
|
const sex = props.data?.user_patient?.sex;
|
||||||
|
if (sex === 1) return '男';
|
||||||
|
if (sex === 2) return '女';
|
||||||
|
return '--';
|
||||||
|
});
|
||||||
|
|
||||||
|
const doctorInfo = computed(
|
||||||
|
() => props.data?.doctor_info || props.data?.doctorInfo || null,
|
||||||
|
);
|
||||||
|
const pharmacistInfo = computed(
|
||||||
|
() => props.data?.pharmacist_info || props.data?.pharmacistInfo || null,
|
||||||
|
);
|
||||||
|
const userPatient = computed(
|
||||||
|
() => props.data?.user_patient || props.data?.userPatient || null,
|
||||||
|
);
|
||||||
|
const registerInfo = computed(
|
||||||
|
() => props.data?.register || null,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="data" class="prescription-source-container">
|
||||||
|
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||||
|
<div class="mb-4 flex items-center">
|
||||||
|
<ShopOutlined class="mr-2 text-xl text-purple-500" />
|
||||||
|
<h2 class="text-xl font-bold">开具机构</h2>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.store" class="grid grid-cols-1 gap-4">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">机构名称:</span>
|
||||||
|
<span class="value">{{ data.store.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="italic text-gray-500">暂无机构信息</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||||
|
<div class="mb-4 flex items-center">
|
||||||
|
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||||
|
<h2 class="text-xl font-bold">时间线</h2>
|
||||||
|
</div>
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem>
|
||||||
|
<div class="font-medium">就诊</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ formatTime(registerInfo?.created_at) }}
|
||||||
|
· {{ userPatient?.name || '--' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm">
|
||||||
|
性别:{{ patientSexText }} · 年龄:{{ userPatient?.age ?? '--' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="data.clinical_diagnose" class="mt-1 text-sm">
|
||||||
|
诊断:{{ data.clinical_diagnose }}
|
||||||
|
</div>
|
||||||
|
</TimelineItem>
|
||||||
|
<TimelineItem>
|
||||||
|
<div class="font-medium">开具</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ data.created_at || '--' }}</div>
|
||||||
|
<div class="mt-1 text-sm">
|
||||||
|
医生:{{ doctorInfo?.name || '--' }} · 科室:{{
|
||||||
|
doctorInfo?.depart?.name || '--'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm">
|
||||||
|
处方编号:{{ data.prescription_no }} · 类型:{{ prescriptionTypeText }}
|
||||||
|
</div>
|
||||||
|
</TimelineItem>
|
||||||
|
<TimelineItem>
|
||||||
|
<div class="font-medium">审核</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ data.pharmacist_view_time || '--' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm">
|
||||||
|
状态:
|
||||||
|
<span :class="`text-${statusColor}-500`">{{ statusText }}</span>
|
||||||
|
· 药师:{{ pharmacistInfo?.name || '--' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="data.reject_reason" class="mt-1 text-sm text-red-500">
|
||||||
|
驳回原因:{{ data.reject_reason }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="data.cancel_remark && Number(data.status) === 2"
|
||||||
|
class="mt-1 text-sm text-red-500"
|
||||||
|
>
|
||||||
|
驳回原因:{{ data.cancel_remark }}
|
||||||
|
</div>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||||
|
<div class="mb-4 flex items-center">
|
||||||
|
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||||
|
<h2 class="text-xl font-bold">处方基本信息</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">处方编号:</span>
|
||||||
|
<span class="value">{{ data.prescription_no }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">处方类型:</span>
|
||||||
|
<span class="value">{{ prescriptionTypeText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">处方状态:</span>
|
||||||
|
<span :class="`text-${statusColor}-500`" class="value">{{
|
||||||
|
statusText
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">总金额:</span>
|
||||||
|
<span class="value font-bold text-red-500"
|
||||||
|
>¥{{ data.total_pay_price }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">过期时间:</span>
|
||||||
|
<span class="value">{{ expireTime }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.reject_reason" class="info-item">
|
||||||
|
<span class="label">驳回原因:</span>
|
||||||
|
<span class="value text-red-500">{{ data.reject_reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||||
|
<div class="mb-4 flex items-center">
|
||||||
|
<MedicineBoxOutlined class="mr-2 text-xl text-green-500" />
|
||||||
|
<h2 class="text-xl font-bold">医生信息</h2>
|
||||||
|
</div>
|
||||||
|
<div v-if="doctorInfo" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">开具医生:</span>
|
||||||
|
<span class="value">{{ doctorInfo.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">开具科室:</span>
|
||||||
|
<span class="value">{{ doctorInfo.depart?.name || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">开具机构:</span>
|
||||||
|
<span class="value">{{ data.store?.name || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">开具时间:</span>
|
||||||
|
<span class="value">{{ data.created_at || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="italic text-gray-500">暂无医生信息</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg p-6 shadow-md">
|
||||||
|
<div class="mb-4 flex items-center">
|
||||||
|
<UserOutlined class="mr-2 text-xl text-amber-500" />
|
||||||
|
<h2 class="text-xl font-bold">患者信息</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="userPatient"
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">患者姓名:</span>
|
||||||
|
<span class="value">{{ userPatient.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">年龄:</span>
|
||||||
|
<span class="value">{{ userPatient.age }}岁</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">性别:</span>
|
||||||
|
<span class="value">{{ patientSexText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="italic text-gray-500">暂无患者信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex h-64 items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-12 w-12 animate-spin rounded-full border-b-2 border-t-2 border-blue-500"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.prescription-source-container {
|
||||||
|
@apply max-h-[70vh] overflow-auto p-4;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
@apply flex flex-col rounded-md p-3;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply mb-1 text-sm text-gray-500;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
@apply font-medium;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
550
apps/web-antd/src/components/store-card/StoreCardModal.vue
Normal file
550
apps/web-antd/src/components/store-card/StoreCardModal.vue
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 门店详情弹窗(诊所/药店)
|
||||||
|
* 布局对齐医生档案:顶部统计 + 左侧竖向 Tabs
|
||||||
|
*/
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Descriptions,
|
||||||
|
Empty,
|
||||||
|
Image,
|
||||||
|
Select,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||||
|
import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api';
|
||||||
|
import {
|
||||||
|
getStoreCardApi,
|
||||||
|
getStoreCardStatsApi,
|
||||||
|
getStoreDrugsByTypeApi,
|
||||||
|
openQrCodeApi,
|
||||||
|
} from '#/views/system/store/api';
|
||||||
|
import { getSalespersonList } from '#/views/system/store/api/salesperson';
|
||||||
|
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||||
|
import AddDoctorFromStoreModal from '#/views/system/store/components/AddDoctorFromStoreModal.vue';
|
||||||
|
import { canAddStoreDoctor } from '#/views/system/admin/_shared/platform-admin-role';
|
||||||
|
|
||||||
|
const RangePicker = DatePicker.RangePicker;
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
// 是否允许「添加医生」:超管/管理员/省经理/市经理/业务员
|
||||||
|
const canAddDoctor = computed(() => canAddStoreDoctor(userStore.userInfo));
|
||||||
|
|
||||||
|
const storeId = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const activeTab = ref('basic');
|
||||||
|
const cardData = ref<any>(null);
|
||||||
|
const searchTime = ref<[Dayjs, Dayjs]>([
|
||||||
|
dayjs().startOf('month'),
|
||||||
|
dayjs(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bankLoading = ref(false);
|
||||||
|
const bankDetail = ref<any>(null);
|
||||||
|
|
||||||
|
const drugType = ref(2);
|
||||||
|
const drugLoading = ref(false);
|
||||||
|
const drugList = ref<any[]>([]);
|
||||||
|
|
||||||
|
const promoterLoading = ref(false);
|
||||||
|
const promoterList = ref<any[]>([]);
|
||||||
|
|
||||||
|
const generatingQr = ref(false);
|
||||||
|
|
||||||
|
const store = computed(() => cardData.value?.store ?? {});
|
||||||
|
const stats = computed(() => cardData.value?.stats_summary ?? {});
|
||||||
|
const doctorList = computed(() => cardData.value?.doctor_list ?? []);
|
||||||
|
const isClinic = computed(() => Number(store.value?.type) === 0);
|
||||||
|
const isPharmacy = computed(() => Number(store.value?.type) === 1);
|
||||||
|
/** 按门店类型区分诊所/药店文案,避免药店仍显示「诊所」 */
|
||||||
|
const storeKindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
|
||||||
|
const qrTabLabel = computed(() => `${storeKindLabel.value}二维码`);
|
||||||
|
const modalTitle = computed(() => {
|
||||||
|
const name = store.value?.name;
|
||||||
|
if (!name) return '门店详情';
|
||||||
|
return `${storeKindLabel.value}详情 · ${name}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const drugTypeOptions = [
|
||||||
|
{ label: '中药', value: 1 },
|
||||||
|
{ label: '西药', value: 2 },
|
||||||
|
{ label: '保健食品', value: 3 },
|
||||||
|
{ label: '产品服务包', value: 5 },
|
||||||
|
{ label: '非药品', value: 6 },
|
||||||
|
{ label: '医疗器械', value: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const drugColumns = [
|
||||||
|
{ title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', ellipsis: true },
|
||||||
|
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 140 },
|
||||||
|
{ title: '售价', dataIndex: 'price', key: 'price', width: 100 },
|
||||||
|
{ title: '进价', dataIndex: 'buy_price', key: 'buy_price', width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const promoterColumns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||||
|
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
class: 'w-[900px] xl:w-[1100px]',
|
||||||
|
fullscreenButton: true,
|
||||||
|
footer: false,
|
||||||
|
draggable: true,
|
||||||
|
closeOnClickModal: false,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
storeId.value = Number(data.storeId || data.id || 0);
|
||||||
|
activeTab.value = 'basic';
|
||||||
|
searchTime.value = [dayjs().startOf('month'), dayjs()];
|
||||||
|
bankDetail.value = null;
|
||||||
|
drugList.value = [];
|
||||||
|
promoterList.value = [];
|
||||||
|
if (storeId.value) {
|
||||||
|
loadCard();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [DoctorModal, DoctorModalApi] = useVbenModal({
|
||||||
|
connectedComponent: DoctorCardModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 添加医生弹窗:诊所详情医生团队 Tab 内入口 */
|
||||||
|
const [AddDoctorModal, AddDoctorModalApi] = useVbenModal({
|
||||||
|
connectedComponent: AddDoctorFromStoreModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开「添加医生」弹窗:默认绑定当前门店,提交成功后刷新医生团队列表
|
||||||
|
*/
|
||||||
|
const openAddDoctorModal = () => {
|
||||||
|
AddDoctorModalApi.setData({
|
||||||
|
storeId: storeId.value,
|
||||||
|
storeName: (store.value as any)?.name || '',
|
||||||
|
onSuccess: () => {
|
||||||
|
loadCard();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
AddDoctorModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
function searchTimeParam(): [string, string] {
|
||||||
|
return [
|
||||||
|
searchTime.value[0].format('YYYY-MM-DD 00:00:00'),
|
||||||
|
searchTime.value[1].format('YYYY-MM-DD 23:59:59'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载门店卡片概览 */
|
||||||
|
async function loadCard() {
|
||||||
|
if (!storeId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getStoreCardApi({
|
||||||
|
id: storeId.value,
|
||||||
|
search_time: searchTimeParam(),
|
||||||
|
});
|
||||||
|
cardData.value = res;
|
||||||
|
if (isPharmacy.value) {
|
||||||
|
drugType.value = 2;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载门店详情失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅刷新统计 */
|
||||||
|
async function refreshStats() {
|
||||||
|
if (!storeId.value) return;
|
||||||
|
try {
|
||||||
|
const res = await getStoreCardStatsApi({
|
||||||
|
id: storeId.value,
|
||||||
|
search_time: searchTimeParam(),
|
||||||
|
});
|
||||||
|
if (cardData.value) {
|
||||||
|
cardData.value = {
|
||||||
|
...cardData.value,
|
||||||
|
stats_summary: res.stats_summary,
|
||||||
|
search_time: res.search_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '刷新统计失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBank() {
|
||||||
|
if (!storeId.value || bankDetail.value) return;
|
||||||
|
bankLoading.value = true;
|
||||||
|
try {
|
||||||
|
bankDetail.value = await getStoreBankCardReportDetail(storeId.value);
|
||||||
|
} catch {
|
||||||
|
bankDetail.value = null;
|
||||||
|
} finally {
|
||||||
|
bankLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDrugs() {
|
||||||
|
if (!storeId.value) return;
|
||||||
|
drugLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getStoreDrugsByTypeApi(storeId.value, drugType.value);
|
||||||
|
const list = Array.isArray(res) ? res : res?.items || res?.list || [];
|
||||||
|
drugList.value = (list || []).map((item: any) => {
|
||||||
|
const drug = item.drug || {};
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
drug_name: drug.drug_name || item.drug_name || '-',
|
||||||
|
specification: drug.specification || item.specification || '-',
|
||||||
|
price: item.price ?? '-',
|
||||||
|
buy_price: item.buy_price ?? '-',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
drugList.value = [];
|
||||||
|
} finally {
|
||||||
|
drugLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPromoters() {
|
||||||
|
if (!storeId.value || promoterList.value.length > 0) return;
|
||||||
|
promoterLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getSalespersonList({
|
||||||
|
store_id: storeId.value,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
const list = res?.items || res?.list?.items || res || [];
|
||||||
|
promoterList.value = Array.isArray(list) ? list : [];
|
||||||
|
} catch {
|
||||||
|
promoterList.value = [];
|
||||||
|
} finally {
|
||||||
|
promoterLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabChange(key: string | number) {
|
||||||
|
activeTab.value = String(key);
|
||||||
|
if (key === 'bank') loadBank();
|
||||||
|
if (key === 'drugs') loadDrugs();
|
||||||
|
if (key === 'promoters') loadPromoters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDoctor(suId: number) {
|
||||||
|
DoctorModalApi.setData({ su_id: suId, hideStoresTab: true, readonly: true });
|
||||||
|
DoctorModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureQrCode() {
|
||||||
|
if (cardData.value?.qr_code || store.value?.qr_code) return;
|
||||||
|
generatingQr.value = true;
|
||||||
|
try {
|
||||||
|
await openQrCodeApi(storeId.value);
|
||||||
|
await loadCard();
|
||||||
|
message.success('二维码已生成');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '生成二维码失败');
|
||||||
|
} finally {
|
||||||
|
generatingQr.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clinicTypeText(v: number) {
|
||||||
|
if (v === 1) return '西医诊所';
|
||||||
|
if (v === 2) return '中医诊所';
|
||||||
|
return '未设置';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="modalTitle">
|
||||||
|
<AddDoctorModal />
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div v-if="cardData" class="mb-4 flex flex-wrap items-center justify-between gap-3 px-1">
|
||||||
|
<div class="text-sm text-muted-foreground">统计时间范围</div>
|
||||||
|
<RangePicker
|
||||||
|
v-model:value="searchTime"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
@change="refreshStats"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶部统计 -->
|
||||||
|
<div
|
||||||
|
v-if="cardData"
|
||||||
|
class="mb-6 grid grid-cols-1 gap-4 px-1 md:grid-cols-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col overflow-hidden rounded-xl border border-blue-100 bg-blue-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-blue-800/50 dark:bg-blue-900/20"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold text-blue-600/80 dark:text-blue-300/80">
|
||||||
|
销售额
|
||||||
|
</span>
|
||||||
|
<div class="mt-2 text-2xl font-bold text-blue-900 dark:text-blue-100">
|
||||||
|
<span class="text-lg">¥</span>
|
||||||
|
{{ Number(stats.sales_amount ?? 0).toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-emerald-800/50 dark:bg-emerald-900/20"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold text-emerald-600/80 dark:text-emerald-300/80">
|
||||||
|
利润
|
||||||
|
</span>
|
||||||
|
<div class="mt-2 text-2xl font-bold text-emerald-900 dark:text-emerald-100">
|
||||||
|
<span class="text-lg">¥</span>
|
||||||
|
{{ Number(stats.profit ?? 0).toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col overflow-hidden rounded-xl border border-purple-100 bg-purple-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-purple-800/50 dark:bg-purple-900/20"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold text-purple-600/80 dark:text-purple-300/80">
|
||||||
|
处方量
|
||||||
|
</span>
|
||||||
|
<div class="mt-2 text-2xl font-bold text-purple-900 dark:text-purple-100">
|
||||||
|
{{ stats.prescription_count ?? 0 }}
|
||||||
|
<span class="text-xs font-normal text-purple-700/60 dark:text-purple-300/60">
|
||||||
|
张
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
:active-key="activeTab"
|
||||||
|
tab-position="left"
|
||||||
|
class="custom-vertical-tabs min-h-[480px]"
|
||||||
|
@change="onTabChange"
|
||||||
|
>
|
||||||
|
<Tabs.TabPane key="basic" tab="门店基本信息">
|
||||||
|
<div class="pl-4">
|
||||||
|
<Descriptions bordered :column="2" size="small">
|
||||||
|
<Descriptions.Item label="名称">
|
||||||
|
{{ store.name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="ID">{{ store.id }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="类型">
|
||||||
|
{{ isPharmacy ? '药店' : '诊所' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item v-if="isClinic" label="诊所类型">
|
||||||
|
{{ clinicTypeText(Number(store.clinic_type)) }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="联系人">
|
||||||
|
{{ store.contact || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机号">
|
||||||
|
{{ store.mobile || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="地址" :span="2">
|
||||||
|
{{ store.position || store.address || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="ERP ID">
|
||||||
|
{{ store.erp_id || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="MES ID">
|
||||||
|
{{ store.mes_id || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="银行卡报备">
|
||||||
|
{{ store.bank_card_report_status_text || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="业务员">
|
||||||
|
{{ store.new_admin?.nick_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane key="bank" tab="银行卡">
|
||||||
|
<div class="pl-4">
|
||||||
|
<Spin :spinning="bankLoading">
|
||||||
|
<template v-if="bankDetail">
|
||||||
|
<Descriptions bordered :column="2" size="small">
|
||||||
|
<Descriptions.Item label="户名">
|
||||||
|
{{ bankDetail.bank_user_name || bankDetail.account_name || store.bank_user_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="卡号">
|
||||||
|
{{ bankDetail.bank_card || bankDetail.card_no || store.bank_card || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{{ bankDetail.bank_name || store.bank_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="联行号">
|
||||||
|
{{ bankDetail.bank_no || store.bank_no || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="报备状态">
|
||||||
|
{{ bankDetail.report_status_text || store.bank_card_report_status_text || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账户类型">
|
||||||
|
{{ bankDetail.bank_account_type ?? store.bank_account_type ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</template>
|
||||||
|
<Empty v-else description="暂无银行卡报备信息,展示门店预留信息">
|
||||||
|
<Descriptions bordered :column="2" size="small" class="mt-4 text-left">
|
||||||
|
<Descriptions.Item label="户名">
|
||||||
|
{{ store.bank_user_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="卡号">
|
||||||
|
{{ store.bank_card || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{{ store.bank_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="联行号">
|
||||||
|
{{ store.bank_no || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Empty>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane v-if="isClinic" key="doctors" tab="诊所医生团队">
|
||||||
|
<div class="pl-4">
|
||||||
|
<div v-if="canAddDoctor" class="mb-3">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openAddDoctorModal"
|
||||||
|
>
|
||||||
|
添加医生
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Empty v-if="doctorList.length === 0" description="暂无医生" />
|
||||||
|
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="item in doctorList"
|
||||||
|
:key="item.su_id"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-gray-100 p-3 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<Avatar :src="resolveAvatarUrl(item.doctor?.avatar)" :size="48" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Button type="link" class="!px-0" @click="openDoctor(item.doctor?.su_id || item.su_id)">
|
||||||
|
{{ item.doctor?.name || '未知医生' }}
|
||||||
|
</Button>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ item.doctor?.mobile || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane v-if="isPharmacy" key="drugs" tab="药店药品">
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted-foreground">药品类型</span>
|
||||||
|
<Select
|
||||||
|
v-model:value="drugType"
|
||||||
|
:options="drugTypeOptions"
|
||||||
|
style="width: 160px"
|
||||||
|
@change="loadDrugs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
:columns="drugColumns"
|
||||||
|
:data-source="drugList"
|
||||||
|
:loading="drugLoading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
:scroll="{ y: 360 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane key="qr" :tab="qrTabLabel">
|
||||||
|
<div class="flex flex-col items-center gap-4 py-6 pl-4">
|
||||||
|
<template v-if="cardData?.qr_code || store.qr_code">
|
||||||
|
<Image
|
||||||
|
:src="cardData?.qr_code || store.qr_code"
|
||||||
|
:width="200"
|
||||||
|
:preview="true"
|
||||||
|
/>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{{ store.name }} {{ qrTabLabel }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Empty :description="`尚未生成${storeKindLabel}二维码`" />
|
||||||
|
<Button type="primary" :loading="generatingQr" @click="ensureQrCode">
|
||||||
|
生成{{ storeKindLabel }}二维码
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane key="promoters" tab="门店推广员">
|
||||||
|
<div class="pl-4">
|
||||||
|
<Table
|
||||||
|
:columns="promoterColumns"
|
||||||
|
:data-source="promoterList"
|
||||||
|
:loading="promoterLoading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
:scroll="{ y: 360 }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'name'">
|
||||||
|
{{ record.name || record.nick_name || '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<Tag>{{ record.status ?? '-' }}</Tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
<DoctorModal />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.custom-vertical-tabs .ant-tabs-nav) {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
:deep(.custom-vertical-tabs .ant-tabs-tab) {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
:deep(.custom-vertical-tabs .ant-tabs-tab-active) {
|
||||||
|
background-color: var(
|
||||||
|
--ant-primary-color-active-deprecated-f-12,
|
||||||
|
rgba(22, 119, 255, 0.08)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
349
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal file
349
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 顶栏 VIP 徽标:点击弹出诊所端风格会员气泡卡
|
||||||
|
* 展示等级名(不展示 CUSTOM/V1 等编码)、开通信息、已开功能,并可查看开通记录
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import { Button, Popover } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
|
||||||
|
import VipBadgeCombo from './VipBadgeCombo.vue';
|
||||||
|
import VipStoreRecordsModal from './VipStoreRecordsModal.vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const popoverOpen = ref(false);
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
const vip = computed(() => (userStore.userInfo as any)?.vip || null);
|
||||||
|
const storeId = computed(() => Number((userStore.userInfo as any)?.store_id || 0));
|
||||||
|
const storeName = computed(
|
||||||
|
() =>
|
||||||
|
String(
|
||||||
|
(userStore.userInfo as any)?.store_name ||
|
||||||
|
(userStore.userInfo as any)?.store?.name ||
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const show = computed(() => !!vip.value);
|
||||||
|
|
||||||
|
/** 只展示等级名称,不展示 CUSTOM/V1 等编码 */
|
||||||
|
const levelName = computed(
|
||||||
|
() => String(vip.value?.level_name || '').trim() || '普通会员',
|
||||||
|
);
|
||||||
|
|
||||||
|
const durationLabel = computed(
|
||||||
|
() => String(vip.value?.duration_label || '').trim() || '普通会员',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 当前等级已开功能列表 */
|
||||||
|
const features = computed(() => {
|
||||||
|
const list = vip.value?.features;
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 到期文案 */
|
||||||
|
const expireText = computed(() => {
|
||||||
|
const v = vip.value;
|
||||||
|
if (!v) return '默认会员';
|
||||||
|
if (v.is_lifetime) return '永久有效';
|
||||||
|
const expireAt = Number(v.expire_at || 0);
|
||||||
|
if (!expireAt) {
|
||||||
|
if ((v.level_code || 'V0') === 'V0') return '默认会员';
|
||||||
|
return '永久有效';
|
||||||
|
}
|
||||||
|
if (typeof v.expire_at === 'string' && String(v.expire_at).includes('-')) {
|
||||||
|
return `有效期至 ${v.expire_at}`;
|
||||||
|
}
|
||||||
|
const d = new Date(expireAt * 1000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `有效期至 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 开通时间文案(opened_at 为时间戳);无数据返回空串 */
|
||||||
|
const openedAtText = computed(() => {
|
||||||
|
const ts = Number(vip.value?.opened_at || 0);
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openTypeText = computed(() =>
|
||||||
|
String(vip.value?.open_type_txt || '').trim(),
|
||||||
|
);
|
||||||
|
const operatorText = computed(() => {
|
||||||
|
const name = String(vip.value?.operator_name || '').trim();
|
||||||
|
if (name) return name;
|
||||||
|
const id = Number(vip.value?.operator_id || 0);
|
||||||
|
if (id > 0) return `管理员#${id}`;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 是否有开通流水信息:无则整块隐藏,避免展示「—」占位 */
|
||||||
|
const hasOpenInfo = computed(() => {
|
||||||
|
const v = vip.value;
|
||||||
|
if (!v) return false;
|
||||||
|
return !!(
|
||||||
|
openTypeText.value ||
|
||||||
|
operatorText.value ||
|
||||||
|
Number(v.opened_at || 0) > 0 ||
|
||||||
|
Number(v.open_record_id || 0) > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasVipVisual = computed(
|
||||||
|
() => !!(vip.value?.badge_url || vip.value?.duration_badge_url),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [RecordsModalComp, recordsModalApi] = useVbenModal({
|
||||||
|
connectedComponent: VipStoreRecordsModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开气泡时刷新 my-info
|
||||||
|
* 为什么:登录态里的 vip 可能是旧缓存,缺少开通人/开通方式
|
||||||
|
*/
|
||||||
|
watch(popoverOpen, async (open) => {
|
||||||
|
if (!open || refreshing.value) return;
|
||||||
|
refreshing.value = true;
|
||||||
|
try {
|
||||||
|
await authStore.fetchUserInfo();
|
||||||
|
} catch {
|
||||||
|
// 刷新失败仍展示本地缓存
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 打开本门店开通记录弹窗 */
|
||||||
|
function openRecords() {
|
||||||
|
if (!storeId.value) return;
|
||||||
|
popoverOpen.value = false;
|
||||||
|
recordsModalApi.setData({
|
||||||
|
store_id: storeId.value,
|
||||||
|
store_name: storeName.value,
|
||||||
|
});
|
||||||
|
recordsModalApi.open();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="show" class="mr-2 flex items-center">
|
||||||
|
<Popover
|
||||||
|
v-model:open="popoverOpen"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
overlay-class-name="header-vip-popover"
|
||||||
|
:overlay-inner-style="{ padding: '0', background: 'transparent', boxShadow: 'none' }"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="header-vip-bubble">
|
||||||
|
<!-- 诊所端风格会员卡头图 -->
|
||||||
|
<div class="header-vip-hero">
|
||||||
|
<VipBadgeCombo
|
||||||
|
v-if="hasVipVisual"
|
||||||
|
size="lg"
|
||||||
|
:badge-url="vip?.badge_url"
|
||||||
|
:duration-badge-url="vip?.duration_badge_url"
|
||||||
|
:level-name="levelName"
|
||||||
|
:duration-label="durationLabel"
|
||||||
|
/>
|
||||||
|
<div v-else class="header-vip-hero__placeholder">V</div>
|
||||||
|
<div class="header-vip-hero__info">
|
||||||
|
<div class="header-vip-hero__title">{{ levelName }}</div>
|
||||||
|
<div class="header-vip-hero__sub">{{ durationLabel }}</div>
|
||||||
|
<div class="header-vip-hero__expire">{{ expireText }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-vip-body">
|
||||||
|
<div v-if="hasOpenInfo" class="header-vip-rows">
|
||||||
|
<div v-if="openTypeText" class="header-vip-row">
|
||||||
|
<span class="header-vip-row__label">开通方式</span>
|
||||||
|
<span class="header-vip-row__value">{{ openTypeText }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="operatorText" class="header-vip-row">
|
||||||
|
<span class="header-vip-row__label">开通人</span>
|
||||||
|
<span class="header-vip-row__value">{{ operatorText }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="openedAtText" class="header-vip-row">
|
||||||
|
<span class="header-vip-row__label">开通时间</span>
|
||||||
|
<span class="header-vip-row__value">{{ openedAtText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="header-vip-features"
|
||||||
|
:class="{ 'header-vip-features--alone': !hasOpenInfo }"
|
||||||
|
>
|
||||||
|
<div class="header-vip-features__title">已开功能</div>
|
||||||
|
<div v-if="features.length" class="header-vip-features__list">
|
||||||
|
<span
|
||||||
|
v-for="item in features"
|
||||||
|
:key="item.code"
|
||||||
|
class="header-vip-feature-tag"
|
||||||
|
:title="item.description || item.name"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="header-vip-features__empty">暂无开通功能</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="storeId > 0 && hasOpenInfo"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
class="mt-3"
|
||||||
|
@click="openRecords"
|
||||||
|
>
|
||||||
|
查看开通记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="header-vip-trigger"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@keydown.enter.prevent="popoverOpen = !popoverOpen"
|
||||||
|
>
|
||||||
|
<VipBadgeCombo
|
||||||
|
size="sm"
|
||||||
|
:badge-url="vip.badge_url"
|
||||||
|
:duration-badge-url="vip.duration_badge_url"
|
||||||
|
:level-name="levelName"
|
||||||
|
:duration-label="durationLabel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
<RecordsModalComp />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-vip-trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.header-vip-trigger:hover {
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
.header-vip-bubble {
|
||||||
|
width: 320px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow: 0 8px 24px hsl(var(--foreground) / 0.12);
|
||||||
|
}
|
||||||
|
.header-vip-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||||
|
}
|
||||||
|
.header-vip-hero__placeholder {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-vip-hero__info {
|
||||||
|
min-width: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.header-vip-hero__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.header-vip-hero__sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.header-vip-hero__expire {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
.header-vip-body {
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
.header-vip-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.header-vip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.header-vip-row__label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.header-vip-row__value {
|
||||||
|
text-align: right;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.header-vip-features {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
.header-vip-features--alone {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.header-vip-features__title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.header-vip-features__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-vip-feature-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 0.12);
|
||||||
|
border: 1px solid hsl(var(--primary) / 0.25);
|
||||||
|
}
|
||||||
|
.header-vip-features__empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
html.dark .header-vip-hero,
|
||||||
|
.dark .header-vip-hero {
|
||||||
|
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal file
79
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 诊所列表 VIP 单元格:徽标+会员名+到期时间,点击打开 VIP 入口弹窗
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||||
|
import { formatVipDurationExpireLine } from '#/utils/vip';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
vip?: Record<string, any> | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/** 副行展示时长类型与到期,方便列表一眼看到过期时间 */
|
||||||
|
const subText = computed(() => formatVipDurationExpireLine(props.vip));
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="store-vip-cell"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click.stop="emit('click')"
|
||||||
|
@keydown.enter.prevent="emit('click')"
|
||||||
|
>
|
||||||
|
<VipBadgeCombo
|
||||||
|
size="sm"
|
||||||
|
:badge-url="vip?.badge_url"
|
||||||
|
:duration-badge-url="vip?.duration_badge_url"
|
||||||
|
:level-name="vip?.level_name"
|
||||||
|
:duration-label="vip?.duration_label"
|
||||||
|
/>
|
||||||
|
<div class="store-vip-cell__text">
|
||||||
|
<div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div>
|
||||||
|
<div class="store-vip-cell__sub">{{ subText }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.store-vip-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px 4px 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.store-vip-cell:hover {
|
||||||
|
background: rgba(106, 205, 187, 0.12);
|
||||||
|
}
|
||||||
|
.store-vip-cell__text {
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.store-vip-cell__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.store-vip-cell__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
html.dark .store-vip-cell:hover,
|
||||||
|
.dark .store-vip-cell:hover {
|
||||||
|
background: rgba(106, 205, 187, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal file
106
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 诊所设置「基础资料」会员卡片
|
||||||
|
* 大尺寸等级徽标 + 期限丝带叠加,展示等级名与到期时间
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import VipBadgeCombo from './VipBadgeCombo.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
vip?: Record<string, any> | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const hasVipVisual = computed(() => {
|
||||||
|
const v = props.vip;
|
||||||
|
return !!(v && (v.badge_url || v.duration_badge_url));
|
||||||
|
});
|
||||||
|
|
||||||
|
const expireText = computed(() => {
|
||||||
|
const v = props.vip;
|
||||||
|
if (!v) return '默认会员';
|
||||||
|
if (v.is_lifetime) return '永久有效';
|
||||||
|
const expireAt = Number(v.expire_at || 0);
|
||||||
|
if (!expireAt) {
|
||||||
|
if ((v.level_code || 'V0') === 'V0') return '默认会员';
|
||||||
|
return '永久有效';
|
||||||
|
}
|
||||||
|
if (typeof v.expire_at === 'string' && String(v.expire_at).includes('-')) {
|
||||||
|
return `有效期至 ${v.expire_at}`;
|
||||||
|
}
|
||||||
|
const d = new Date(expireAt * 1000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `有效期至 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="store-vip-card">
|
||||||
|
<VipBadgeCombo
|
||||||
|
v-if="hasVipVisual"
|
||||||
|
size="xl"
|
||||||
|
:badge-url="vip?.badge_url"
|
||||||
|
:duration-badge-url="vip?.duration_badge_url"
|
||||||
|
:level-name="vip?.level_name"
|
||||||
|
:duration-label="vip?.duration_label"
|
||||||
|
/>
|
||||||
|
<div v-else class="store-vip-card__placeholder">V</div>
|
||||||
|
<div class="store-vip-card__info">
|
||||||
|
<div class="store-vip-card__title">
|
||||||
|
{{ vip?.level_name || '普通会员' }}
|
||||||
|
</div>
|
||||||
|
<div class="store-vip-card__sub">
|
||||||
|
{{ vip?.duration_label || '普通会员' }}
|
||||||
|
</div>
|
||||||
|
<div class="store-vip-card__expire">{{ expireText }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.store-vip-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 20px 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||||
|
box-shadow: 0 8px 24px rgba(44, 62, 80, 0.16);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.store-vip-card__placeholder {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.store-vip-card__info {
|
||||||
|
min-width: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.store-vip-card__title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.store-vip-card__sub {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.store-vip-card__expire {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
html.dark .store-vip-card,
|
||||||
|
.dark .store-vip-card {
|
||||||
|
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal file
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* VIP 组合徽标:等级徽标在下,期限丝带(透明底)叠在上方绑定
|
||||||
|
* size: sm 顶栏 / md 表格 / lg 详情 / xl 诊所设置会员卡
|
||||||
|
*/
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
badgeUrl?: string;
|
||||||
|
durationBadgeUrl?: string;
|
||||||
|
levelName?: string;
|
||||||
|
durationLabel?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
badgeUrl: '',
|
||||||
|
durationBadgeUrl: '',
|
||||||
|
levelName: '',
|
||||||
|
durationLabel: '',
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="badgeUrl || durationBadgeUrl"
|
||||||
|
class="vip-badge-combo"
|
||||||
|
:class="[`vip-badge-combo--${size}`]"
|
||||||
|
:title="[durationLabel, levelName].filter(Boolean).join(' · ')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="badgeUrl"
|
||||||
|
class="vip-badge-combo__level"
|
||||||
|
:src="badgeUrl"
|
||||||
|
:alt="levelName || '会员等级'"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="durationBadgeUrl"
|
||||||
|
class="vip-badge-combo__ribbon"
|
||||||
|
:src="durationBadgeUrl"
|
||||||
|
:alt="durationLabel || '会员类型'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.vip-badge-combo {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.vip-badge-combo__level {
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.vip-badge-combo__ribbon {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 8%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
object-fit: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--sm {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--sm .vip-badge-combo__level {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--sm .vip-badge-combo__ribbon {
|
||||||
|
width: 72px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--md {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--md .vip-badge-combo__level {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--md .vip-badge-combo__ribbon {
|
||||||
|
width: 84px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--lg {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--lg .vip-badge-combo__level {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--lg .vip-badge-combo__ribbon {
|
||||||
|
width: 120px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--xl {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--xl .vip-badge-combo__level {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
.vip-badge-combo--xl .vip-badge-combo__ribbon {
|
||||||
|
width: 168px;
|
||||||
|
height: 58px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
apps/web-antd/src/components/vip/VipGate.vue
Normal file
56
apps/web-antd/src/components/vip/VipGate.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* VIP 权限显隐全局组件
|
||||||
|
* 用法:
|
||||||
|
* <VipGate code="medical_record">有权限才渲染</VipGate>
|
||||||
|
* <VipGate :code="VIP_FEATURE.AI_PRESCRIPTION" :vip="receptionVip">...</VipGate>
|
||||||
|
* 接诊/复诊务必传 :vip="履约诊所vip",不要只靠登录态
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasVipPermission,
|
||||||
|
levelAtLeast,
|
||||||
|
type StoreVipInfo,
|
||||||
|
} from '#/utils/vip';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 权限码(与 xk_vip_feature.code / 等级 permissions 对齐) */
|
||||||
|
code?: string;
|
||||||
|
/** 最低等级编码 */
|
||||||
|
minLevel?: string;
|
||||||
|
/**
|
||||||
|
* 指定 VIP 来源(挂号履约诊所);不传则用登录态 userInfo.vip
|
||||||
|
*/
|
||||||
|
vip?: StoreVipInfo | string[] | null;
|
||||||
|
/** 无权限时是否渲染 fallback 插槽(默认不渲染) */
|
||||||
|
fallback?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
code: '',
|
||||||
|
minLevel: '',
|
||||||
|
fallback: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowed = computed(() => {
|
||||||
|
if (props.code) {
|
||||||
|
// 传了 :vip 则按履约诊所判;未传则回落登录态
|
||||||
|
if (props.vip !== undefined) {
|
||||||
|
return hasVipPermission(props.code, props.vip);
|
||||||
|
}
|
||||||
|
return hasVipPermission(props.code);
|
||||||
|
}
|
||||||
|
if (props.minLevel) {
|
||||||
|
const vipObj = Array.isArray(props.vip) ? null : (props.vip ?? null);
|
||||||
|
return levelAtLeast(props.minLevel, vipObj);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<slot v-if="allowed" />
|
||||||
|
<slot v-else-if="fallback" name="fallback" />
|
||||||
|
</template>
|
||||||
233
apps/web-antd/src/components/vip/VipHubModal.vue
Normal file
233
apps/web-antd/src/components/vip/VipHubModal.vue
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 门店 VIP 入口弹窗
|
||||||
|
* 先展示两张操作卡片:开通/升级、开通记录;再分别打开对应子弹窗
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||||
|
import VipStoreRecordsModal from '#/components/vip/VipStoreRecordsModal.vue';
|
||||||
|
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
|
||||||
|
import { formatVipExpireAt } from '#/utils/vip';
|
||||||
|
|
||||||
|
const gridApi = ref<any>();
|
||||||
|
const onSuccess = ref<null | (() => void)>(null);
|
||||||
|
const storeId = ref(0);
|
||||||
|
const storeName = ref('');
|
||||||
|
const vip = ref<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
const title = computed(
|
||||||
|
() => `VIP管理 - ${storeName.value || storeId.value || ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 当前等级 + 时长类型 */
|
||||||
|
const currentLevelText = computed(() => {
|
||||||
|
const v = vip.value;
|
||||||
|
if (!v) return '当前:普通会员';
|
||||||
|
const name = String(v.level_name || '').trim();
|
||||||
|
const code = String(v.level_code || '').trim();
|
||||||
|
const duration = String(v.duration_label || '').trim();
|
||||||
|
const level =
|
||||||
|
name && code ? `${name}(${code})` : name || code || '普通会员';
|
||||||
|
return duration ? `当前:${level} · ${duration}` : `当前:${level}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 到期时间独立一行,打开弹窗时一眼可见 */
|
||||||
|
const currentExpireText = computed(() => {
|
||||||
|
const v = vip.value;
|
||||||
|
if (!v) return '';
|
||||||
|
const code = String(v.level_code || 'V0').trim() || 'V0';
|
||||||
|
if (code === 'V0') return '';
|
||||||
|
const expire = formatVipExpireAt(v, {
|
||||||
|
lifetimeText: '终身',
|
||||||
|
withPrefix: false,
|
||||||
|
});
|
||||||
|
if (!expire) return '';
|
||||||
|
return expire === '终身' ? '到期时间:终身' : `到期时间:${expire}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [UpgradeModalComp, upgradeModalApi] = useVbenModal({
|
||||||
|
connectedComponent: VipUpgradeModal,
|
||||||
|
});
|
||||||
|
const [RecordsModalComp, recordsModalApi] = useVbenModal({
|
||||||
|
connectedComponent: VipStoreRecordsModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
class: 'w-[640px]',
|
||||||
|
showConfirmButton: false,
|
||||||
|
cancelText: '关闭',
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
storeId.value = 0;
|
||||||
|
storeName.value = '';
|
||||||
|
vip.value = null;
|
||||||
|
gridApi.value = null;
|
||||||
|
onSuccess.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
storeId.value = Number(data.store_id || 0);
|
||||||
|
storeName.value = String(data.store_name || '');
|
||||||
|
vip.value = data.vip || null;
|
||||||
|
gridApi.value = data.gridApi || null;
|
||||||
|
onSuccess.value = data.onSuccess || null;
|
||||||
|
modalApi.setState({ title: title.value });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开开通/升级弹窗
|
||||||
|
* 为什么不直接嵌在本弹窗:升级表单已有独立组件,复用可保持交互一致
|
||||||
|
*/
|
||||||
|
function openUpgrade() {
|
||||||
|
if (!storeId.value) return;
|
||||||
|
upgradeModalApi.setData({
|
||||||
|
store_id: storeId.value,
|
||||||
|
store_name: storeName.value,
|
||||||
|
vip: vip.value,
|
||||||
|
gridApi: gridApi.value,
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccess.value?.();
|
||||||
|
gridApi.value?.query?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upgradeModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开当前门店开通记录(含开通人) */
|
||||||
|
function openRecords() {
|
||||||
|
if (!storeId.value) return;
|
||||||
|
recordsModalApi.setData({
|
||||||
|
store_id: storeId.value,
|
||||||
|
store_name: storeName.value,
|
||||||
|
});
|
||||||
|
recordsModalApi.open();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<div class="vip-hub-current mb-4">
|
||||||
|
<VipBadgeCombo
|
||||||
|
size="md"
|
||||||
|
:badge-url="vip?.badge_url"
|
||||||
|
:duration-badge-url="vip?.duration_badge_url"
|
||||||
|
:level-name="vip?.level_name"
|
||||||
|
:duration-label="vip?.duration_label"
|
||||||
|
/>
|
||||||
|
<div class="vip-hub-current__meta">
|
||||||
|
<div class="vip-hub-current__text">{{ currentLevelText }}</div>
|
||||||
|
<div v-if="currentExpireText" class="vip-hub-current__expire">
|
||||||
|
{{ currentExpireText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vip-hub-grid">
|
||||||
|
<button type="button" class="vip-hub-card" @click="openUpgrade">
|
||||||
|
<div class="vip-hub-card__icon">↑</div>
|
||||||
|
<div class="vip-hub-card__title">开通 / 升级</div>
|
||||||
|
<div class="vip-hub-card__desc">
|
||||||
|
为该门店开通或调整会员等级与时长
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="vip-hub-card" @click="openRecords">
|
||||||
|
<div class="vip-hub-card__icon">☰</div>
|
||||||
|
<div class="vip-hub-card__title">开通记录</div>
|
||||||
|
<div class="vip-hub-card__desc">
|
||||||
|
查看历史开通流水与开通人
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<UpgradeModalComp />
|
||||||
|
<RecordsModalComp />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vip-hub-current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsl(var(--muted) / 0.25);
|
||||||
|
}
|
||||||
|
.vip-hub-current__meta {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.vip-hub-current__text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.vip-hub-current__expire {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.vip-hub-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.vip-hub-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 148px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.vip-hub-card:hover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 0.08);
|
||||||
|
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.2);
|
||||||
|
}
|
||||||
|
.vip-hub-card__icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 0.12);
|
||||||
|
}
|
||||||
|
.vip-hub-card__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.vip-hub-card__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.vip-hub-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
196
apps/web-antd/src/components/vip/VipStoreRecordsModal.vue
Normal file
196
apps/web-antd/src/components/vip/VipStoreRecordsModal.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 门店 VIP 开通记录弹窗
|
||||||
|
* 按 store_id 拉取开通流水,重点展示开通人、等级、时效与开通时间
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Empty, Pagination, Spin, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||||
|
import { getVipRecordList } from '#/views/system/vip/record/api';
|
||||||
|
|
||||||
|
const storeId = ref(0);
|
||||||
|
const storeName = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const items = ref<any[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
|
||||||
|
const title = computed(
|
||||||
|
() => `开通记录 - ${storeName.value || storeId.value || ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取当前门店开通记录
|
||||||
|
* 为什么单独封装:弹窗打开、翻页都要复用同一套查询参数
|
||||||
|
*/
|
||||||
|
async function loadList() {
|
||||||
|
if (!storeId.value) {
|
||||||
|
items.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getVipRecordList({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
store_id: storeId.value,
|
||||||
|
});
|
||||||
|
items.value = Array.isArray(res?.items) ? res.items : [];
|
||||||
|
total.value = Number(res?.total || 0);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等级展示:只展示名称,不展示 CUSTOM/V1 等编码 */
|
||||||
|
function levelLabel(row: Record<string, any>) {
|
||||||
|
return String(row?.level_name || '').trim() || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开通人兜底:无操作人时标明系统/未知 */
|
||||||
|
function operatorLabel(row: Record<string, any>) {
|
||||||
|
const name = String(row?.operator_name || '').trim();
|
||||||
|
if (name) return name;
|
||||||
|
const id = Number(row?.operator_id || 0);
|
||||||
|
if (id > 0) return `管理员#${id}`;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
class: 'w-[820px]',
|
||||||
|
showConfirmButton: false,
|
||||||
|
cancelText: '关闭',
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
storeId.value = 0;
|
||||||
|
storeName.value = '';
|
||||||
|
items.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
page.value = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
storeId.value = Number(data.store_id || 0);
|
||||||
|
storeName.value = String(data.store_name || '');
|
||||||
|
page.value = 1;
|
||||||
|
modalApi.setState({ title: title.value });
|
||||||
|
await loadList();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 分页切换 */
|
||||||
|
function onPageChange(current: number, size: number) {
|
||||||
|
page.value = current;
|
||||||
|
pageSize.value = size;
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Empty
|
||||||
|
v-if="!loading && !items.length"
|
||||||
|
description="暂无开通记录"
|
||||||
|
/>
|
||||||
|
<div v-else class="vip-record-list">
|
||||||
|
<div
|
||||||
|
v-for="row in items"
|
||||||
|
:key="row.id"
|
||||||
|
class="vip-record-card"
|
||||||
|
>
|
||||||
|
<div class="vip-record-card__badge">
|
||||||
|
<VipBadgeCombo
|
||||||
|
size="md"
|
||||||
|
:badge-url="row.badge_url"
|
||||||
|
:duration-badge-url="row.duration_badge_url"
|
||||||
|
:level-name="row.level_name"
|
||||||
|
:duration-label="row.duration_label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="vip-record-card__body">
|
||||||
|
<div class="vip-record-card__title">
|
||||||
|
{{ levelLabel(row) }}
|
||||||
|
<Tag class="ml-2">{{ row.duration_label || '—' }}</Tag>
|
||||||
|
<Tag color="blue">{{ row.open_type_txt || '—' }}</Tag>
|
||||||
|
</div>
|
||||||
|
<div class="vip-record-card__meta">
|
||||||
|
<span>开通人:{{ operatorLabel(row) }}</span>
|
||||||
|
<span>开通时间:{{ row.created_at || '—' }}</span>
|
||||||
|
<span>到期:{{ row.expire_at || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="vip-record-card__meta">
|
||||||
|
<span>原价 ¥{{ row.original_price ?? '0.00' }}</span>
|
||||||
|
<span>实付 ¥{{ row.pay_amount ?? '0.00' }}</span>
|
||||||
|
<span v-if="row.remark">备注:{{ row.remark }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="total > 0" class="mt-4 flex justify-end">
|
||||||
|
<Pagination
|
||||||
|
v-model:current="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
size="small"
|
||||||
|
show-size-changer
|
||||||
|
@change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vip-record-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.vip-record-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
}
|
||||||
|
.vip-record-card__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vip-record-card__body {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.vip-record-card__title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.vip-record-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
496
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal file
496
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* VIP 升级/开通弹窗
|
||||||
|
* 等级/时长用两行横向滑动单选卡片,预览丝带随所选时长自动切换
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Input, InputNumber, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||||
|
import { getVipDurationOption } from '#/views/system/vip/duration/api';
|
||||||
|
import { getVipLevelOption } from '#/views/system/vip/level/api';
|
||||||
|
import {
|
||||||
|
getVipDurationPresets,
|
||||||
|
openVipStore,
|
||||||
|
} from '#/views/system/vip/store/api';
|
||||||
|
|
||||||
|
/** 时长预设 → 时效类型 code(与后端 VipDurationHelper 对齐;keep 走当前会员 duration_type) */
|
||||||
|
const PRESET_TO_DURATION: Record<string, string> = {
|
||||||
|
'7d': 'trial',
|
||||||
|
'1w': 'week',
|
||||||
|
'1m': 'month',
|
||||||
|
'3m': 'month',
|
||||||
|
'6m': 'month',
|
||||||
|
'1y': 'year',
|
||||||
|
xy: 'year',
|
||||||
|
lifetime: 'lifetime',
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridApi = ref<any>();
|
||||||
|
const onSuccess = ref<null | (() => void)>(null);
|
||||||
|
const storeId = ref(0);
|
||||||
|
const storeName = ref('');
|
||||||
|
/** 打开弹窗时带入的当前门店 VIP,用于判断能否「继承剩余时间」 */
|
||||||
|
const currentVip = ref<Record<string, any> | null>(null);
|
||||||
|
const levelId = ref<number | undefined>();
|
||||||
|
const durationPreset = ref<string | undefined>();
|
||||||
|
const customYears = ref<number | undefined>();
|
||||||
|
const remark = ref('');
|
||||||
|
|
||||||
|
const levels = ref<any[]>([]);
|
||||||
|
const presets = ref<{ label: string; value: string }[]>([]);
|
||||||
|
const durationTypes = ref<any[]>([]);
|
||||||
|
|
||||||
|
const selectedLevel = computed(() =>
|
||||||
|
levels.value.find((i) => i.id === levelId.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否可继承剩余时间:非普通会员(非 V0)且未过期
|
||||||
|
* 普通会员没有可继承的到期时间,不展示/不默认 keep
|
||||||
|
*/
|
||||||
|
const canKeepDuration = computed(() => {
|
||||||
|
const v = currentVip.value;
|
||||||
|
if (!v) return false;
|
||||||
|
const code = String(v.level_code || 'V0').trim() || 'V0';
|
||||||
|
if (code === 'V0') return false;
|
||||||
|
const expireAt = Number(v.expire_at || 0);
|
||||||
|
// expire_at=0 表示终身,视为可继承;>0 须未过期
|
||||||
|
if (expireAt > 0 && expireAt * 1000 <= Date.now()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 按时长预设解析对应丝带资源 */
|
||||||
|
function ribbonByPreset(preset?: string) {
|
||||||
|
if (preset === 'keep') {
|
||||||
|
const code = String(currentVip.value?.duration_type || '').trim();
|
||||||
|
if (!code) return null;
|
||||||
|
return durationTypes.value.find((i) => i.code === code) || null;
|
||||||
|
}
|
||||||
|
const code = PRESET_TO_DURATION[preset || ''] || '';
|
||||||
|
if (!code) return null;
|
||||||
|
return durationTypes.value.find((i) => i.code === code) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDurationType = computed(() =>
|
||||||
|
ribbonByPreset(durationPreset.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 时长卡片:普通会员隐藏 keep;其它预设照常 */
|
||||||
|
const durationCards = computed(() =>
|
||||||
|
presets.value
|
||||||
|
.filter((p) => p.value !== 'keep' || canKeepDuration.value)
|
||||||
|
.map((p) => {
|
||||||
|
const ribbon = ribbonByPreset(p.value);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
ribbonUrl: ribbon?.badge_url || '',
|
||||||
|
ribbonName: ribbon?.name || '',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewBadgeUrl = computed(() => selectedLevel.value?.badge_url || '');
|
||||||
|
const previewRibbonUrl = computed(
|
||||||
|
() => selectedDurationType.value?.badge_url || '',
|
||||||
|
);
|
||||||
|
const previewLevelName = computed(
|
||||||
|
() => selectedLevel.value?.name || '请选择等级',
|
||||||
|
);
|
||||||
|
const previewDurationLabel = computed(() => {
|
||||||
|
if (!durationPreset.value) return '请选择时长';
|
||||||
|
if (durationPreset.value === 'keep') {
|
||||||
|
const label = String(currentVip.value?.duration_label || '').trim();
|
||||||
|
const expireHint = formatVipExpireHint(currentVip.value);
|
||||||
|
if (label && expireHint) return `继承剩余:${label}(${expireHint})`;
|
||||||
|
if (label) return `继承剩余:${label}`;
|
||||||
|
return '继承剩余时间(不改动)';
|
||||||
|
}
|
||||||
|
const preset = presets.value.find((p) => p.value === durationPreset.value);
|
||||||
|
return preset?.label || selectedDurationType.value?.name || '请选择时长';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化当前会员到期提示(终身 / 已格式化字符串 / 时间戳)
|
||||||
|
*/
|
||||||
|
function formatVipExpireHint(v: Record<string, any> | null | undefined): string {
|
||||||
|
if (!v) return '';
|
||||||
|
if (v.is_lifetime) return '终身';
|
||||||
|
const raw = v.expire_at;
|
||||||
|
if (raw == null || raw === '' || raw === 0) return '';
|
||||||
|
if (typeof raw === 'string' && Number.isNaN(Number(raw))) {
|
||||||
|
return `至 ${raw}`;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!(n > 0)) return '';
|
||||||
|
const d = new Date(n * 1000);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const pad = (x: number) => String(x).padStart(2, '0');
|
||||||
|
return `至 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() => `升级VIP - ${storeName.value || storeId.value}`);
|
||||||
|
|
||||||
|
/** 选择会员等级 */
|
||||||
|
function selectLevel(id: number) {
|
||||||
|
levelId.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择开通时长(同步驱动预览丝带) */
|
||||||
|
function selectDuration(value: string) {
|
||||||
|
durationPreset.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onConfirm: async () => {
|
||||||
|
if (!levelId.value) {
|
||||||
|
message.warning('请选择会员等级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!durationPreset.value) {
|
||||||
|
message.warning('请选择开通时长');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
durationPreset.value === 'xy' &&
|
||||||
|
!(Number(customYears.value) > 0)
|
||||||
|
) {
|
||||||
|
message.warning('请填写有效年数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.setState({ confirmLoading: true });
|
||||||
|
try {
|
||||||
|
await openVipStore({
|
||||||
|
store_id: storeId.value,
|
||||||
|
level_id: levelId.value,
|
||||||
|
duration_preset: durationPreset.value,
|
||||||
|
custom_years: customYears.value || 0,
|
||||||
|
remark: remark.value || '',
|
||||||
|
});
|
||||||
|
message.success('开通成功');
|
||||||
|
gridApi.value?.query?.();
|
||||||
|
onSuccess.value?.();
|
||||||
|
modalApi.close();
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ confirmLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
levelId.value = undefined;
|
||||||
|
durationPreset.value = undefined;
|
||||||
|
customYears.value = undefined;
|
||||||
|
remark.value = '';
|
||||||
|
currentVip.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
gridApi.value = data.gridApi;
|
||||||
|
onSuccess.value = data.onSuccess || null;
|
||||||
|
storeId.value = Number(data.store_id || 0);
|
||||||
|
storeName.value = data.store_name || '';
|
||||||
|
currentVip.value = data.vip || null;
|
||||||
|
const [levelList, presetList, durationList] = await Promise.all([
|
||||||
|
getVipLevelOption(),
|
||||||
|
getVipDurationPresets(),
|
||||||
|
getVipDurationOption(),
|
||||||
|
]);
|
||||||
|
levels.value = levelList || [];
|
||||||
|
presets.value = (presetList || []).map((i: any) => ({
|
||||||
|
label: i.label,
|
||||||
|
value: i.value,
|
||||||
|
}));
|
||||||
|
durationTypes.value = durationList || [];
|
||||||
|
// 回填当前 VIP 等级(便于续期/改档)
|
||||||
|
if (data.vip?.level_id) {
|
||||||
|
levelId.value = Number(data.vip.level_id);
|
||||||
|
}
|
||||||
|
// 非普通会员:默认选「继承剩余时间」,到期日不改动
|
||||||
|
if (canKeepDuration.value) {
|
||||||
|
durationPreset.value = 'keep';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(durationPreset, (v) => {
|
||||||
|
if (v !== 'xy') customYears.value = undefined;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal :title="title" class="w-[50%]">
|
||||||
|
<!-- 预览:徽标卡片 + 丝带(随时长自动适配) -->
|
||||||
|
<div class="vip-upgrade-preview mb-5">
|
||||||
|
<VipBadgeCombo
|
||||||
|
size="xl"
|
||||||
|
:badge-url="previewBadgeUrl"
|
||||||
|
:duration-badge-url="previewRibbonUrl"
|
||||||
|
:level-name="previewLevelName"
|
||||||
|
:duration-label="previewDurationLabel"
|
||||||
|
/>
|
||||||
|
<div class="vip-upgrade-preview__text">
|
||||||
|
<div class="vip-upgrade-preview__title">{{ previewLevelName }}</div>
|
||||||
|
<div class="vip-upgrade-preview__sub">{{ previewDurationLabel }}</div>
|
||||||
|
<div v-if="selectedLevel" class="vip-upgrade-preview__price">
|
||||||
|
原价 ¥{{ selectedLevel.price }} · 管理员开通实付 ¥0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
会员等级
|
||||||
|
</div>
|
||||||
|
<div class="vip-pick-scroll" role="listbox" aria-label="会员等级">
|
||||||
|
<button
|
||||||
|
v-for="item in levels"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="vip-pick-card"
|
||||||
|
:class="{ 'vip-pick-card--active': levelId === item.id }"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="levelId === item.id"
|
||||||
|
@click="selectLevel(item.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.badge_url"
|
||||||
|
:src="item.badge_url"
|
||||||
|
alt=""
|
||||||
|
class="vip-pick-card__badge"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="vip-pick-card__badge vip-pick-card__badge--empty"
|
||||||
|
>
|
||||||
|
{{ item.code }}
|
||||||
|
</div>
|
||||||
|
<div class="vip-pick-card__name">{{ item.name }}</div>
|
||||||
|
<div class="vip-pick-card__meta">
|
||||||
|
{{ item.code }} · ¥{{ item.price }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
开通时长
|
||||||
|
</div>
|
||||||
|
<div class="vip-pick-scroll" role="listbox" aria-label="开通时长">
|
||||||
|
<button
|
||||||
|
v-for="item in durationCards"
|
||||||
|
:key="item.value"
|
||||||
|
type="button"
|
||||||
|
class="vip-pick-card vip-pick-card--duration"
|
||||||
|
:class="{ 'vip-pick-card--active': durationPreset === item.value }"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="durationPreset === item.value"
|
||||||
|
@click="selectDuration(item.value)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.ribbonUrl"
|
||||||
|
:src="item.ribbonUrl"
|
||||||
|
alt=""
|
||||||
|
class="vip-pick-card__ribbon"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="vip-pick-card__ribbon vip-pick-card__ribbon--empty"
|
||||||
|
>
|
||||||
|
丝带
|
||||||
|
</div>
|
||||||
|
<div class="vip-pick-card__name">{{ item.label }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="durationPreset === 'xy'">
|
||||||
|
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
自定义年数
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="customYears"
|
||||||
|
class="w-full"
|
||||||
|
:min="1"
|
||||||
|
:max="99"
|
||||||
|
placeholder="请输入年数"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">备注</div>
|
||||||
|
<Input.TextArea v-model:value="remark" :rows="2" placeholder="选填" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.vip-upgrade-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.vip-upgrade-preview__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.vip-upgrade-preview__sub {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.vip-upgrade-preview__price {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
/* 两行 + 横向滑动的单选卡片轨道 */
|
||||||
|
.vip-pick-scroll {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-template-rows: repeat(2, minmax(0, auto));
|
||||||
|
grid-auto-columns: 112px;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 4px 2px 10px;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.vip-pick-scroll::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.vip-pick-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.vip-pick-card {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 108px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 1.5px solid #e8e8e8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.vip-pick-card:hover {
|
||||||
|
border-color: #6acdbb;
|
||||||
|
}
|
||||||
|
.vip-pick-card--active {
|
||||||
|
border-color: #6acdbb;
|
||||||
|
background: rgba(106, 205, 187, 0.1);
|
||||||
|
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.25);
|
||||||
|
}
|
||||||
|
.vip-pick-card__badge {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.vip-pick-card__badge--empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vip-pick-card__ribbon {
|
||||||
|
width: 88px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.vip-pick-card__ribbon--empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 88px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.vip-pick-card--duration {
|
||||||
|
min-height: 88px;
|
||||||
|
}
|
||||||
|
.vip-pick-card__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f1f1f;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.vip-pick-card__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* ========== 暗色适配(html.dark / .dark)========== */
|
||||||
|
html.dark .vip-upgrade-preview,
|
||||||
|
.dark .vip-upgrade-preview {
|
||||||
|
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(106, 205, 187, 0.18);
|
||||||
|
}
|
||||||
|
html.dark .vip-pick-scroll::-webkit-scrollbar-thumb,
|
||||||
|
.dark .vip-pick-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
html.dark .vip-pick-card,
|
||||||
|
.dark .vip-pick-card {
|
||||||
|
border-color: #3f3f46;
|
||||||
|
background: #1f1f23;
|
||||||
|
}
|
||||||
|
html.dark .vip-pick-card:hover,
|
||||||
|
.dark .vip-pick-card:hover {
|
||||||
|
border-color: #6acdbb;
|
||||||
|
background: #27272a;
|
||||||
|
}
|
||||||
|
html.dark .vip-pick-card--active,
|
||||||
|
.dark .vip-pick-card--active {
|
||||||
|
border-color: #6acdbb;
|
||||||
|
background: rgba(106, 205, 187, 0.16);
|
||||||
|
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.28);
|
||||||
|
}
|
||||||
|
html.dark .vip-pick-card__badge--empty,
|
||||||
|
.dark .vip-pick-card__badge--empty,
|
||||||
|
html.dark .vip-pick-card__ribbon--empty,
|
||||||
|
.dark .vip-pick-card__ribbon--empty {
|
||||||
|
background: #2a2a2e;
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
html.dark .vip-pick-card__name,
|
||||||
|
.dark .vip-pick-card__name {
|
||||||
|
color: #f4f4f5;
|
||||||
|
}
|
||||||
|
html.dark .vip-pick-card__meta,
|
||||||
|
.dark .vip-pick-card__meta {
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,6 +43,10 @@ export interface WarehouseDrugItem {
|
|||||||
id: number;
|
id: number;
|
||||||
drug_name: string;
|
drug_name: string;
|
||||||
pinyin_simple?: string;
|
pinyin_simple?: string;
|
||||||
|
/** 关键词命中的别名(后端 matched_alias) */
|
||||||
|
matched_alias?: string;
|
||||||
|
/** 命中别名对应拼音展示 */
|
||||||
|
matched_alias_pinyin?: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
type?: number;
|
type?: number;
|
||||||
@@ -212,8 +216,27 @@ watch(
|
|||||||
<div v-else class="warehouse-drug-item__img-placeholder">无图</div>
|
<div v-else class="warehouse-drug-item__img-placeholder">无图</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="warehouse-drug-item__info">
|
<div class="warehouse-drug-item__info">
|
||||||
<div class="warehouse-drug-item__name">{{ item.drug_name }}</div>
|
<div
|
||||||
|
class="warehouse-drug-item__name"
|
||||||
|
:title="
|
||||||
|
item.matched_alias
|
||||||
|
? `${item.drug_name}(别名:${item.matched_alias})`
|
||||||
|
: item.drug_name
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ item.drug_name }}
|
||||||
|
</div>
|
||||||
|
<!-- 与开方气泡一致:命中别名时展示别名及拼音 -->
|
||||||
|
<div v-if="item.matched_alias" class="warehouse-drug-item__alias">
|
||||||
|
别名:{{ item.matched_alias }}
|
||||||
|
<span v-if="item.matched_alias_pinyin">
|
||||||
|
({{ item.matched_alias_pinyin }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="warehouse-drug-item__meta">
|
<div class="warehouse-drug-item__meta">
|
||||||
|
<span v-if="item.pinyin_simple && !item.matched_alias" class="warehouse-drug-item__py">
|
||||||
|
拼音:{{ item.pinyin_simple }}
|
||||||
|
</span>
|
||||||
<span v-if="item.specification" class="warehouse-drug-item__spec">
|
<span v-if="item.specification" class="warehouse-drug-item__spec">
|
||||||
规格:{{ item.specification }}
|
规格:{{ item.specification }}
|
||||||
</span>
|
</span>
|
||||||
@@ -321,7 +344,17 @@ watch(
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__alias {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
line-height: 1.3;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__meta {
|
||||||
@@ -332,6 +365,10 @@ watch(
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__py {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
&__spec {
|
&__spec {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 处方失效时间展示
|
||||||
|
* - 剩余 ≤2 小时:标红
|
||||||
|
* - 剩余 ≤10 分钟:额外显示倒计时(每秒刷新)
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 失效时间戳(秒)或已格式化的字符串 */
|
||||||
|
autoExpireTime?: number | string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const nowTs = ref(Math.floor(Date.now() / 1000));
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** 解析为秒级时间戳 */
|
||||||
|
function resolveExpireTs(raw: number | string | null | undefined): number {
|
||||||
|
if (raw == null || raw === '') return 0;
|
||||||
|
if (typeof raw === 'number') {
|
||||||
|
return raw > 1e12 ? Math.floor(raw / 1000) : raw;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (Number.isFinite(n) && n > 0) {
|
||||||
|
return n > 1e12 ? Math.floor(n / 1000) : n;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(String(raw).replace(/-/g, '/'));
|
||||||
|
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expireTs = computed(() => resolveExpireTs(props.autoExpireTime));
|
||||||
|
|
||||||
|
const remainSec = computed(() => {
|
||||||
|
if (!expireTs.value) return null;
|
||||||
|
return expireTs.value - nowTs.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUrgent = computed(() => {
|
||||||
|
const r = remainSec.value;
|
||||||
|
return r != null && r > 0 && r <= 2 * 3600;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showCountdown = computed(() => {
|
||||||
|
const r = remainSec.value;
|
||||||
|
return r != null && r > 0 && r <= 10 * 60;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
if (!expireTs.value) return '—';
|
||||||
|
const r = remainSec.value ?? 0;
|
||||||
|
if (r <= 0) return '已过期';
|
||||||
|
const d = new Date(expireTs.value * 1000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
if (showCountdown.value) {
|
||||||
|
const m = Math.floor(r / 60);
|
||||||
|
const s = r % 60;
|
||||||
|
return `${base}(剩余 ${m}:${pad(s)})`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
stopTimer();
|
||||||
|
timer = setInterval(() => {
|
||||||
|
nowTs.value = Math.floor(Date.now() / 1000);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.autoExpireTime,
|
||||||
|
() => {
|
||||||
|
nowTs.value = Math.floor(Date.now() / 1000);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(startTimer);
|
||||||
|
onBeforeUnmount(stopTimer);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="expire-time"
|
||||||
|
:class="{
|
||||||
|
'expire-time--urgent': isUrgent || (remainSec != null && remainSec <= 0),
|
||||||
|
'expire-time--countdown': showCountdown,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ displayText }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.expire-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.expire-time--urgent {
|
||||||
|
color: #cf1322;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.expire-time--countdown {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 就诊类型标签:线下就诊 / 在线问诊 / 在线复诊
|
||||||
|
* 对应处方、订单字段 is_online
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 就诊渠道:0线下 1在线问诊 2/3在线复诊 */
|
||||||
|
isOnline?: number | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
const v = Number(props.isOnline ?? 0);
|
||||||
|
if (v === 1) return { text: '在线问诊', color: 'blue' };
|
||||||
|
if (v === 2 || v === 3) return { text: '在线复诊', color: 'green' };
|
||||||
|
return { text: '线下就诊', color: 'purple' };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Tag :color="label.color">{{ label.text }}</Tag>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 列表/卡片内紧凑展示:小程序用户 + 就诊人
|
||||||
|
* 点击后由父级打开 WxUserPatientDrawer
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Avatar, Button } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 审方/订单行数据,需含 user、user_patient(或 patient) */
|
||||||
|
row: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
open: [payload: { upId: number; patientName: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const defaultAvatar = '/img/user-default-avatar.png';
|
||||||
|
|
||||||
|
const user = computed(() => props.row?.user ?? {});
|
||||||
|
const patient = computed(
|
||||||
|
() => props.row?.user_patient ?? props.row?.userPatient ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const upId = computed(() =>
|
||||||
|
Number(patient.value?.id ?? props.row?.up_id ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const patientName = computed(
|
||||||
|
() => String(patient.value?.name ?? props.row?.patient ?? '') || '—',
|
||||||
|
);
|
||||||
|
|
||||||
|
function userAvatarSrc() {
|
||||||
|
const raw = String(user.value?.avatarurl ?? '').trim();
|
||||||
|
if (!raw) return defaultAvatar;
|
||||||
|
return resolveAvatarUrl(raw) || defaultAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开就诊人档案抽屉 */
|
||||||
|
function handleOpen() {
|
||||||
|
if (!upId.value) return;
|
||||||
|
emit('open', { upId: upId.value, patientName: patientName.value });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wx-user-patient-cell">
|
||||||
|
<div class="wx-user-patient-cell__row">
|
||||||
|
<Avatar :size="24" :src="userAvatarSrc()" class="shrink-0" />
|
||||||
|
<div class="wx-user-patient-cell__text min-w-0">
|
||||||
|
<span class="text-[11px] text-gray-400">微信用户:</span>
|
||||||
|
<span class="truncate text-xs">{{ user?.nickname || '—' }}</span>
|
||||||
|
<div class="text-[11px] text-gray-500">ID:{{ user?.id ?? '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wx-user-patient-cell__row">
|
||||||
|
<Avatar :size="24" :src="defaultAvatar" class="shrink-0" />
|
||||||
|
<div class="wx-user-patient-cell__text min-w-0">
|
||||||
|
<span class="text-[11px] text-gray-400">就诊人:</span>
|
||||||
|
<Button
|
||||||
|
v-if="upId"
|
||||||
|
class="!h-auto !px-0 !py-0"
|
||||||
|
type="link"
|
||||||
|
@click.stop="handleOpen"
|
||||||
|
>
|
||||||
|
{{ patientName }}
|
||||||
|
</Button>
|
||||||
|
<span v-else class="text-xs">{{ patientName }}</span>
|
||||||
|
<div
|
||||||
|
v-if="patient?.sex || patient?.age"
|
||||||
|
class="text-[11px] text-gray-500"
|
||||||
|
>
|
||||||
|
<template v-if="patient?.sex === 1">男</template>
|
||||||
|
<template v-else-if="patient?.sex === 2">女</template>
|
||||||
|
<template v-if="patient?.age"> · {{ patient.age }}岁</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wx-user-patient-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wx-user-patient-cell__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wx-user-patient-cell__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 就诊人详情 Modal(患者视角)
|
||||||
|
* 与 Drawer 内容一致:资料 + 挂号/处方/订单;会员管理、订单「就诊人」入口使用
|
||||||
|
*/
|
||||||
|
import { h, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Empty,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||||
|
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||||
|
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||||
|
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getUserPatientOrderListApi,
|
||||||
|
getUserPatientPrescriptionListApi,
|
||||||
|
getUserPatientProfileDetailApi,
|
||||||
|
getUserPatientRegisterListApi,
|
||||||
|
} from './api';
|
||||||
|
import VisitTypeTag from './VisitTypeTag.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'WxUserPatientDetailModal' });
|
||||||
|
|
||||||
|
const defaultAvatar = '/img/user-default-avatar.png';
|
||||||
|
|
||||||
|
const upId = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const activeTab = ref('info');
|
||||||
|
const profile = ref<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
const registerList = ref<any[]>([]);
|
||||||
|
const prescriptionList = ref<any[]>([]);
|
||||||
|
const orderList = ref<any[]>([]);
|
||||||
|
const registerLoading = ref(false);
|
||||||
|
const prescriptionLoading = ref(false);
|
||||||
|
const orderLoading = ref(false);
|
||||||
|
|
||||||
|
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: PrescriptionDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: OrderDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
footer: false,
|
||||||
|
class: 'w-[760px]',
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
profile.value = null;
|
||||||
|
registerList.value = [];
|
||||||
|
prescriptionList.value = [];
|
||||||
|
orderList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<{ upId?: number; patientName?: string }>();
|
||||||
|
upId.value = Number(data?.upId || 0);
|
||||||
|
activeTab.value = 'info';
|
||||||
|
const name = String(data?.patientName || '').trim();
|
||||||
|
modalApi.setState({ title: name ? `就诊人:${name}` : '就诊人详情' });
|
||||||
|
if (upId.value > 0) {
|
||||||
|
void loadDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 解析头像 URL */
|
||||||
|
function avatarSrc(raw?: string) {
|
||||||
|
const s = String(raw ?? '').trim();
|
||||||
|
if (!s) return defaultAvatar;
|
||||||
|
return resolveAvatarUrl(s) || defaultAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 既往/过敏/家族:0 无,否则展示 history */
|
||||||
|
function historyText(status?: number, history?: string) {
|
||||||
|
if (Number(status) === 0) return '无';
|
||||||
|
const t = String(history || '').trim();
|
||||||
|
return t || '有';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 肝/肾功能:0 正常,异常时附带指标文案 */
|
||||||
|
function functionText(flag?: number, indexText?: string) {
|
||||||
|
if (Number(flag) === 0) return '正常';
|
||||||
|
const t = String(indexText || '').trim();
|
||||||
|
return t ? `异常 · ${t}` : '异常';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
profile.value = await getUserPatientProfileDetailApi(upId.value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取用户信息失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegisterList() {
|
||||||
|
registerLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getUserPatientRegisterListApi(upId.value);
|
||||||
|
registerList.value = res?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取挂号记录失败');
|
||||||
|
} finally {
|
||||||
|
registerLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrescriptionList() {
|
||||||
|
prescriptionLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getUserPatientPrescriptionListApi(upId.value);
|
||||||
|
prescriptionList.value = res?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取处方记录失败');
|
||||||
|
} finally {
|
||||||
|
prescriptionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrderList() {
|
||||||
|
orderLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getUserPatientOrderListApi(upId.value);
|
||||||
|
orderList.value = res?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取订单记录失败');
|
||||||
|
} finally {
|
||||||
|
orderLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 切换时懒加载对应列表 */
|
||||||
|
function onTabChange(key: string | number) {
|
||||||
|
const k = String(key);
|
||||||
|
activeTab.value = k;
|
||||||
|
if (k === 'register' && registerList.value.length === 0) {
|
||||||
|
void loadRegisterList();
|
||||||
|
} else if (k === 'prescription' && prescriptionList.value.length === 0) {
|
||||||
|
void loadPrescriptionList();
|
||||||
|
} else if (k === 'order' && orderList.value.length === 0) {
|
||||||
|
void loadOrderList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPrescription(id: number) {
|
||||||
|
PrescriptionDetailModalApi.setData({ values: id });
|
||||||
|
PrescriptionDetailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrder(id: number) {
|
||||||
|
OrderDetailModalApi.setData({ id });
|
||||||
|
OrderDetailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerColumns = [
|
||||||
|
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no' },
|
||||||
|
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||||
|
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||||
|
{
|
||||||
|
title: '费用',
|
||||||
|
dataIndex: 'price',
|
||||||
|
key: 'price',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
`¥${Number(text || 0).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付',
|
||||||
|
dataIndex: 'is_pay',
|
||||||
|
key: 'is_pay',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||||
|
text === 1 ? '已支付' : '未支付',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '状态', dataIndex: 'status_text', key: 'status_text' },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const prescriptionColumns = [
|
||||||
|
{
|
||||||
|
title: '处方编号',
|
||||||
|
dataIndex: 'prescription_no',
|
||||||
|
key: 'prescription_no',
|
||||||
|
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ type: 'link', onClick: () => openPrescription(record.id) },
|
||||||
|
() => text,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '就诊类型',
|
||||||
|
dataIndex: 'is_online',
|
||||||
|
key: 'is_online',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(VisitTypeTag, { isOnline: text }),
|
||||||
|
},
|
||||||
|
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||||
|
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||||
|
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
customRender: ({ text }: { text: number }) => {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
0: '待审核',
|
||||||
|
1: '已通过',
|
||||||
|
2: '未通过',
|
||||||
|
3: '无需审核',
|
||||||
|
};
|
||||||
|
return map[text] || '未知';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderColumns = [
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'order_no',
|
||||||
|
key: 'order_no',
|
||||||
|
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||||
|
h(Button, { type: 'link', onClick: () => openOrder(record.id) }, () => text),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '就诊类型',
|
||||||
|
dataIndex: 'is_online',
|
||||||
|
key: 'is_online',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(VisitTypeTag, { isOnline: text }),
|
||||||
|
},
|
||||||
|
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||||
|
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||||
|
{
|
||||||
|
title: '实付',
|
||||||
|
dataIndex: 'total_pay_price',
|
||||||
|
key: 'total_pay_price',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
`¥${Number(text || 0).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付',
|
||||||
|
dataIndex: 'is_pay',
|
||||||
|
key: 'is_pay',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||||
|
text === 1 ? '已支付' : '未支付',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<PrescriptionDetailModal />
|
||||||
|
<OrderDetailModal />
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<template v-if="profile">
|
||||||
|
<div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-slate-800">
|
||||||
|
<Avatar :size="48" :src="avatarSrc(profile.user?.avatarurl)" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ profile.user?.nickname || '—' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
用户 ID:{{ profile.user?.id ?? '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs :active-key="activeTab" @change="onTabChange">
|
||||||
|
<Tabs.TabPane key="info" tab="就诊人信息">
|
||||||
|
<Descriptions :column="2" bordered size="small">
|
||||||
|
<Descriptions.Item label="姓名">
|
||||||
|
{{ profile.patient?.name || '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="性别">
|
||||||
|
{{
|
||||||
|
profile.patient?.sex === 1
|
||||||
|
? '男'
|
||||||
|
: profile.patient?.sex === 2
|
||||||
|
? '女'
|
||||||
|
: '—'
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="年龄">
|
||||||
|
{{ profile.patient?.age ? `${profile.patient.age}岁` : '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机号">
|
||||||
|
<SensitiveText
|
||||||
|
v-if="profile.patient"
|
||||||
|
:record="profile.patient"
|
||||||
|
field="mobile"
|
||||||
|
/>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
v-if="profile.patient?.id_card"
|
||||||
|
label="身份证"
|
||||||
|
:span="2"
|
||||||
|
>
|
||||||
|
<SensitiveText
|
||||||
|
:record="profile.patient"
|
||||||
|
field="id_card"
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<!-- 健康问诊 / 功能异常(与小程序资料 Tab 一致) -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-2 text-sm font-medium text-gray-700">健康信息</div>
|
||||||
|
<Descriptions
|
||||||
|
v-if="profile.health_inquiry"
|
||||||
|
:column="2"
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="现病史">
|
||||||
|
{{
|
||||||
|
historyText(
|
||||||
|
profile.health_inquiry.present_status,
|
||||||
|
profile.health_inquiry.present_history,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="既往史">
|
||||||
|
{{
|
||||||
|
historyText(
|
||||||
|
profile.health_inquiry.person_status,
|
||||||
|
profile.health_inquiry.person_history,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="过敏史">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
Number(profile.health_inquiry.allergic_status) !== 0
|
||||||
|
? 'text-red-600 font-medium'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
historyText(
|
||||||
|
profile.health_inquiry.allergic_status,
|
||||||
|
profile.health_inquiry.allergic_history,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="家族遗传史">
|
||||||
|
{{
|
||||||
|
historyText(
|
||||||
|
profile.health_inquiry.family_status,
|
||||||
|
profile.health_inquiry.family_history,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="肝功能异常">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
Number(profile.health_inquiry.liver_function) !== 0
|
||||||
|
? 'text-red-600 font-medium'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
functionText(
|
||||||
|
profile.health_inquiry.liver_function,
|
||||||
|
profile.health_inquiry.liver_index,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="肾功能异常" :span="2">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
Number(profile.health_inquiry.renal_function) !== 0
|
||||||
|
? 'text-red-600 font-medium'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
functionText(
|
||||||
|
profile.health_inquiry.renal_function,
|
||||||
|
profile.health_inquiry.renal_index,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Empty v-else description="暂无健康问诊记录" />
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="register" tab="挂号记录">
|
||||||
|
<Spin :spinning="registerLoading">
|
||||||
|
<Table
|
||||||
|
v-if="registerList.length"
|
||||||
|
:columns="registerColumns"
|
||||||
|
:data-source="registerList"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Empty v-else description="暂无挂号记录" />
|
||||||
|
</Spin>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="prescription" tab="处方记录">
|
||||||
|
<Spin :spinning="prescriptionLoading">
|
||||||
|
<Table
|
||||||
|
v-if="prescriptionList.length"
|
||||||
|
:columns="prescriptionColumns"
|
||||||
|
:data-source="prescriptionList"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Empty v-else description="暂无处方记录" />
|
||||||
|
</Spin>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="order" tab="订单记录">
|
||||||
|
<Spin :spinning="orderLoading">
|
||||||
|
<Table
|
||||||
|
v-if="orderList.length"
|
||||||
|
:columns="orderColumns"
|
||||||
|
:data-source="orderList"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Empty v-else description="暂无订单记录" />
|
||||||
|
</Spin>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</template>
|
||||||
|
<Empty v-else-if="!loading" description="暂无数据" />
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 微信用户 + 就诊人档案抽屉
|
||||||
|
* 展示小程序用户基础信息、就诊人信息及挂号/处方/订单记录(跨医生)
|
||||||
|
*/
|
||||||
|
import { h, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Empty,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||||
|
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||||
|
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||||
|
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getUserPatientOrderListApi,
|
||||||
|
getUserPatientPrescriptionListApi,
|
||||||
|
getUserPatientProfileDetailApi,
|
||||||
|
getUserPatientRegisterListApi,
|
||||||
|
} from './api';
|
||||||
|
import VisitTypeTag from './VisitTypeTag.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'WxUserPatientDrawer' });
|
||||||
|
|
||||||
|
const defaultAvatar = '/img/user-default-avatar.png';
|
||||||
|
|
||||||
|
const upId = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const activeTab = ref('info');
|
||||||
|
const profile = ref<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
const registerList = ref<any[]>([]);
|
||||||
|
const prescriptionList = ref<any[]>([]);
|
||||||
|
const orderList = ref<any[]>([]);
|
||||||
|
const registerLoading = ref(false);
|
||||||
|
const prescriptionLoading = ref(false);
|
||||||
|
const orderLoading = ref(false);
|
||||||
|
|
||||||
|
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: PrescriptionDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: OrderDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Drawer, drawerApi] = useVbenDrawer({
|
||||||
|
footer: false,
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
profile.value = null;
|
||||||
|
registerList.value = [];
|
||||||
|
prescriptionList.value = [];
|
||||||
|
orderList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = drawerApi.getData<{ upId?: number; patientName?: string }>();
|
||||||
|
upId.value = Number(data?.upId || 0);
|
||||||
|
activeTab.value = 'info';
|
||||||
|
if (upId.value > 0) {
|
||||||
|
void loadDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 解析头像 URL */
|
||||||
|
function avatarSrc(raw?: string) {
|
||||||
|
const s = String(raw ?? '').trim();
|
||||||
|
if (!s) return defaultAvatar;
|
||||||
|
return resolveAvatarUrl(s) || defaultAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
profile.value = await getUserPatientProfileDetailApi(upId.value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取用户信息失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegisterList() {
|
||||||
|
registerLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getUserPatientRegisterListApi(upId.value);
|
||||||
|
registerList.value = res?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取挂号记录失败');
|
||||||
|
} finally {
|
||||||
|
registerLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrescriptionList() {
|
||||||
|
prescriptionLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getUserPatientPrescriptionListApi(upId.value);
|
||||||
|
prescriptionList.value = res?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取处方记录失败');
|
||||||
|
} finally {
|
||||||
|
prescriptionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrderList() {
|
||||||
|
orderLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getUserPatientOrderListApi(upId.value);
|
||||||
|
orderList.value = res?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('获取订单记录失败');
|
||||||
|
} finally {
|
||||||
|
orderLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 切换时懒加载对应列表 */
|
||||||
|
function onTabChange(key: string | number) {
|
||||||
|
const k = String(key);
|
||||||
|
activeTab.value = k;
|
||||||
|
if (k === 'register' && registerList.value.length === 0) {
|
||||||
|
void loadRegisterList();
|
||||||
|
} else if (k === 'prescription' && prescriptionList.value.length === 0) {
|
||||||
|
void loadPrescriptionList();
|
||||||
|
} else if (k === 'order' && orderList.value.length === 0) {
|
||||||
|
void loadOrderList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPrescription(id: number) {
|
||||||
|
PrescriptionDetailModalApi.setData({ values: id });
|
||||||
|
PrescriptionDetailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrder(id: number) {
|
||||||
|
OrderDetailModalApi.setData({ id });
|
||||||
|
OrderDetailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerColumns = [
|
||||||
|
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no' },
|
||||||
|
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||||
|
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||||
|
{
|
||||||
|
title: '费用',
|
||||||
|
dataIndex: 'price',
|
||||||
|
key: 'price',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
`¥${Number(text || 0).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付',
|
||||||
|
dataIndex: 'is_pay',
|
||||||
|
key: 'is_pay',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||||
|
text === 1 ? '已支付' : '未支付',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '状态', dataIndex: 'status_text', key: 'status_text' },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const prescriptionColumns = [
|
||||||
|
{
|
||||||
|
title: '处方编号',
|
||||||
|
dataIndex: 'prescription_no',
|
||||||
|
key: 'prescription_no',
|
||||||
|
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ type: 'link', onClick: () => openPrescription(record.id) },
|
||||||
|
() => text,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '就诊类型',
|
||||||
|
dataIndex: 'is_online',
|
||||||
|
key: 'is_online',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(VisitTypeTag, { isOnline: text }),
|
||||||
|
},
|
||||||
|
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||||
|
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||||
|
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
customRender: ({ text }: { text: number }) => {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
0: '待审核',
|
||||||
|
1: '已通过',
|
||||||
|
2: '未通过',
|
||||||
|
3: '无需审核',
|
||||||
|
};
|
||||||
|
return map[text] || '未知';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderColumns = [
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'order_no',
|
||||||
|
key: 'order_no',
|
||||||
|
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||||
|
h(Button, { type: 'link', onClick: () => openOrder(record.id) }, () => text),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '就诊类型',
|
||||||
|
dataIndex: 'is_online',
|
||||||
|
key: 'is_online',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(VisitTypeTag, { isOnline: text }),
|
||||||
|
},
|
||||||
|
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||||
|
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||||
|
{
|
||||||
|
title: '实付',
|
||||||
|
dataIndex: 'total_pay_price',
|
||||||
|
key: 'total_pay_price',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
`¥${Number(text || 0).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付',
|
||||||
|
dataIndex: 'is_pay',
|
||||||
|
key: 'is_pay',
|
||||||
|
customRender: ({ text }: { text: number }) =>
|
||||||
|
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||||
|
text === 1 ? '已支付' : '未支付',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer class="w-[720px]" title="微信用户 / 就诊人">
|
||||||
|
<PrescriptionDetailModal />
|
||||||
|
<OrderDetailModal />
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<template v-if="profile">
|
||||||
|
<!-- 小程序用户 -->
|
||||||
|
<div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-slate-800">
|
||||||
|
<Avatar :size="48" :src="avatarSrc(profile.user?.avatarurl)" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ profile.user?.nickname || '—' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
用户 ID:{{ profile.user?.id ?? '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs :active-key="activeTab" @change="onTabChange">
|
||||||
|
<Tabs.TabPane key="info" tab="就诊人信息">
|
||||||
|
<Descriptions :column="2" bordered size="small">
|
||||||
|
<Descriptions.Item label="姓名">
|
||||||
|
{{ profile.patient?.name || '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="性别">
|
||||||
|
{{
|
||||||
|
profile.patient?.sex === 1
|
||||||
|
? '男'
|
||||||
|
: profile.patient?.sex === 2
|
||||||
|
? '女'
|
||||||
|
: '—'
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="年龄">
|
||||||
|
{{ profile.patient?.age ? `${profile.patient.age}岁` : '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机号">
|
||||||
|
<SensitiveText
|
||||||
|
v-if="profile.patient"
|
||||||
|
:record="profile.patient"
|
||||||
|
field="mobile"
|
||||||
|
/>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
v-if="profile.patient?.id_card"
|
||||||
|
label="身份证"
|
||||||
|
:span="2"
|
||||||
|
>
|
||||||
|
<SensitiveText
|
||||||
|
:record="profile.patient"
|
||||||
|
field="id_card"
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="register" tab="挂号记录">
|
||||||
|
<Spin :spinning="registerLoading">
|
||||||
|
<Table
|
||||||
|
v-if="registerList.length"
|
||||||
|
:columns="registerColumns"
|
||||||
|
:data-source="registerList"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Empty v-else description="暂无挂号记录" />
|
||||||
|
</Spin>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="prescription" tab="处方记录">
|
||||||
|
<Spin :spinning="prescriptionLoading">
|
||||||
|
<Table
|
||||||
|
v-if="prescriptionList.length"
|
||||||
|
:columns="prescriptionColumns"
|
||||||
|
:data-source="prescriptionList"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Empty v-else description="暂无处方记录" />
|
||||||
|
</Spin>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="order" tab="订单记录">
|
||||||
|
<Spin :spinning="orderLoading">
|
||||||
|
<Table
|
||||||
|
v-if="orderList.length"
|
||||||
|
:columns="orderColumns"
|
||||||
|
:data-source="orderList"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Empty v-else description="暂无订单记录" />
|
||||||
|
</Spin>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</template>
|
||||||
|
<Empty v-else-if="!loading" description="暂无数据" />
|
||||||
|
</Spin>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 某微信用户下的就诊人列表 Modal
|
||||||
|
* 点行再打开就诊人详情 Modal(由父级或本组件内嵌 DetailModal 处理)
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Avatar, Button, Empty, Spin, Table, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||||
|
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||||
|
|
||||||
|
import { getPatientListByUserApi } from './api';
|
||||||
|
import WxUserPatientDetailModal from './WxUserPatientDetailModal.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'WxUserPatientsModal' });
|
||||||
|
|
||||||
|
const defaultAvatar = '/img/user-default-avatar.png';
|
||||||
|
|
||||||
|
const userId = ref(0);
|
||||||
|
const userNickname = ref('');
|
||||||
|
const userAvatar = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const list = ref<any[]>([]);
|
||||||
|
|
||||||
|
const [DetailModal, DetailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: WxUserPatientDetailModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
footer: false,
|
||||||
|
class: 'w-[640px]',
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
list.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<{
|
||||||
|
userId?: number;
|
||||||
|
nickname?: string;
|
||||||
|
avatarurl?: string;
|
||||||
|
}>();
|
||||||
|
userId.value = Number(data?.userId || 0);
|
||||||
|
userNickname.value = String(data?.nickname || '');
|
||||||
|
userAvatar.value = String(data?.avatarurl || '');
|
||||||
|
modalApi.setState({
|
||||||
|
title: userNickname.value
|
||||||
|
? `就诊人列表 · ${userNickname.value}`
|
||||||
|
: '就诊人列表',
|
||||||
|
});
|
||||||
|
if (userId.value > 0) {
|
||||||
|
void loadList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function avatarSrc(raw?: string) {
|
||||||
|
const s = String(raw ?? '').trim();
|
||||||
|
if (!s) return defaultAvatar;
|
||||||
|
return resolveAvatarUrl(s) || defaultAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getPatientListByUserApi(userId.value);
|
||||||
|
list.value = res?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('加载就诊人失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开就诊人详情 Modal(与微信用户列表分离) */
|
||||||
|
function openPatientDetail(row: Record<string, any>) {
|
||||||
|
const upId = Number(row.up_id || 0);
|
||||||
|
if (!upId) {
|
||||||
|
message.warning('缺少就诊人信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DetailModalApi.setData({
|
||||||
|
upId,
|
||||||
|
patientName: row.name || '',
|
||||||
|
});
|
||||||
|
DetailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '姓名', key: 'name' },
|
||||||
|
{ title: '性别', key: 'sex', width: 80 },
|
||||||
|
{ title: '手机', key: 'mobile' },
|
||||||
|
{ title: '操作', key: 'action', width: 100 },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<DetailModal />
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<Avatar :size="36" :src="avatarSrc(userAvatar)" />
|
||||||
|
<div class="min-w-0 text-sm">
|
||||||
|
<div class="font-medium">{{ userNickname || '—' }}</div>
|
||||||
|
<div class="text-xs text-gray-500">用户 ID:{{ userId || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Table
|
||||||
|
v-if="list.length"
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="list"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="up_id"
|
||||||
|
size="small"
|
||||||
|
:custom-row="
|
||||||
|
(record) => ({
|
||||||
|
onClick: () => openPatientDetail(record),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'name'">
|
||||||
|
{{ record.name || '—' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'sex'">
|
||||||
|
<template v-if="record.sex === 1">男</template>
|
||||||
|
<template v-else-if="record.sex === 2">女</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'mobile'">
|
||||||
|
<SensitiveText :record="record" field="mobile" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<Button type="link" @click.stop="openPatientDetail(record)">
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Empty v-else-if="!loading" description="暂无就诊人" />
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
68
apps/web-antd/src/components/wx-user-patient/api.ts
Normal file
68
apps/web-antd/src/components/wx-user-patient/api.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 就诊人档案 API(跨医生视角,供药师审方等场景)
|
||||||
|
*/
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'user-patient-profile/';
|
||||||
|
|
||||||
|
/** 用户管理列表(微信用户 + 就诊人,旧接口) */
|
||||||
|
export async function getUserPatientProfileListApi(params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会员管理:微信用户分页 */
|
||||||
|
export async function getWxUserListApi(params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.get<any>(`${prefix}user-list`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某微信用户下的就诊人列表 */
|
||||||
|
export async function getPatientListByUserApi(userId: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}patient-list-by-user`, {
|
||||||
|
params: { user_id: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 小程序用户 + 就诊人基础信息 */
|
||||||
|
export async function getUserPatientProfileDetailApi(upId: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, {
|
||||||
|
params: { up_id: upId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 就诊人挂号记录 */
|
||||||
|
export async function getUserPatientRegisterListApi(
|
||||||
|
upId: number,
|
||||||
|
params?: { page?: number; pageSize?: number },
|
||||||
|
) {
|
||||||
|
return requestClient.get<any>(`${prefix}register-list`, {
|
||||||
|
params: { up_id: upId, ...params },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 就诊人处方记录 */
|
||||||
|
export async function getUserPatientPrescriptionListApi(
|
||||||
|
upId: number,
|
||||||
|
params?: { page?: number; pageSize?: number },
|
||||||
|
) {
|
||||||
|
return requestClient.get<any>(`${prefix}prescription-list`, {
|
||||||
|
params: { up_id: upId, ...params },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 就诊人商品订单记录 */
|
||||||
|
export async function getUserPatientOrderListApi(
|
||||||
|
upId: number,
|
||||||
|
params?: { page?: number; pageSize?: number },
|
||||||
|
) {
|
||||||
|
return requestClient.get<any>(`${prefix}order-list`, {
|
||||||
|
params: { up_id: upId, ...params },
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/web-antd/src/components/wx-user-patient/index.ts
Normal file
10
apps/web-antd/src/components/wx-user-patient/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* 微信用户/就诊人相关组件导出
|
||||||
|
*/
|
||||||
|
export { default as WxUserPatientCell } from './WxUserPatientCell.vue';
|
||||||
|
export { default as WxUserPatientDrawer } from './WxUserPatientDrawer.vue';
|
||||||
|
export { default as WxUserPatientDetailModal } from './WxUserPatientDetailModal.vue';
|
||||||
|
export { default as WxUserPatientsModal } from './WxUserPatientsModal.vue';
|
||||||
|
export { default as VisitTypeTag } from './VisitTypeTag.vue';
|
||||||
|
export { default as PrescriptionExpireTime } from './PrescriptionExpireTime.vue';
|
||||||
|
export * from './api';
|
||||||
@@ -17,6 +17,8 @@ export const ENCRYPT_FIELDS = new Set([
|
|||||||
'password',
|
'password',
|
||||||
'patient_mobile',
|
'patient_mobile',
|
||||||
'phone',
|
'phone',
|
||||||
|
'open_api_key',
|
||||||
|
'api_key',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */
|
/** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */
|
||||||
@@ -96,7 +98,7 @@ export function maskSensitiveField(field: string, plainText: unknown): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const str = String(plainText);
|
const str = String(plainText);
|
||||||
if (field === 'password') {
|
if (field === 'password' || field === 'api_key' || field === 'open_api_key') {
|
||||||
return '*'.repeat(Math.max(str.length, 6));
|
return '*'.repeat(Math.max(str.length, 6));
|
||||||
}
|
}
|
||||||
if (PHONE_FIELDS.has(field)) {
|
if (PHONE_FIELDS.has(field)) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const appName = computed(() => preferences.app.name);
|
const appName = computed(() => preferences.app.name);
|
||||||
const logo = computed(() => preferences.logo.source);
|
const logo = computed(() => preferences.logo.source);
|
||||||
|
const logoDark = computed(() => preferences.logo.sourceDark);
|
||||||
// const logo = 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/logo.png?Expires=1734595037&OSSAccessKeyId=TMP.3KhpnCAjxkdTGWSj1ZCdYe3e9c6MBfEVZwN1CiHdUKUAT2D1mPEpACYgYMkUiQDVqy9Zqb7VqgUoh21W37FC3CztogHJPN&Signature=KmymeelO2OZxaAmvm2ACDK5UfF8%3D';
|
// const logo = 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/logo.png?Expires=1734595037&OSSAccessKeyId=TMP.3KhpnCAjxkdTGWSj1ZCdYe3e9c6MBfEVZwN1CiHdUKUAT2D1mPEpACYgYMkUiQDVqy9Zqb7VqgUoh21W37FC3CztogHJPN&Signature=KmymeelO2OZxaAmvm2ACDK5UfF8%3D';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const logo = computed(() => preferences.logo.source);
|
|||||||
<AuthPageLayout
|
<AuthPageLayout
|
||||||
:app-name="appName"
|
:app-name="appName"
|
||||||
:logo="logo"
|
:logo="logo"
|
||||||
|
:logo-dark="logoDark"
|
||||||
:page-description="$t('authentication.pageDesc')"
|
:page-description="$t('authentication.pageDesc')"
|
||||||
:page-title="$t('authentication.pageTitle')"
|
:page-title="$t('authentication.pageTitle')"
|
||||||
:version="preferences.app.version"
|
:version="preferences.app.version"
|
||||||
|
|||||||
@@ -1,185 +1,197 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 管理端基础布局:铃铛站内信轮询 + 动作宿主挂载
|
||||||
|
*/
|
||||||
import type { NotificationItem } from '@vben/layouts';
|
import type { NotificationItem } from '@vben/layouts';
|
||||||
|
|
||||||
import {computed, onUnmounted, ref, watch} from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
import { AuthenticationLoginExpiredModal, VbenIcon } from '@vben/common-ui';
|
||||||
|
|
||||||
// import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
|
||||||
import { useWatermark } from '@vben/hooks';
|
import { useWatermark } from '@vben/hooks';
|
||||||
// import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
|
import { $t } from '@vben/locales';
|
||||||
// import { CircleHelp } from '@vben/icons';
|
|
||||||
import {
|
import {
|
||||||
BasicLayout,
|
BasicLayout,
|
||||||
LockScreen,
|
LockScreen,
|
||||||
Notification,
|
Notification,
|
||||||
UserDropdown,
|
UserDropdown,
|
||||||
} from '@vben/layouts';
|
} from '@vben/layouts';
|
||||||
import { preferences } from '@vben/preferences';
|
import { preferences, usePreferences } from '@vben/preferences';
|
||||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||||
// import { openWindow } from '@vben/utils';
|
|
||||||
|
|
||||||
import {
|
import { notification } from 'ant-design-vue';
|
||||||
Bell,
|
import { Bell, Users } from 'lucide-vue-next';
|
||||||
CalendarClock,
|
|
||||||
Megaphone,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-vue-next';
|
|
||||||
|
|
||||||
// import { $t } from '#/locales';
|
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
||||||
import { useAuthStore } from '#/store';
|
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
|
||||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
|
||||||
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
|
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useAuthStore } from '#/store';
|
||||||
import {getNoticeListApi, readAllApi} from "#/views/notice/api";
|
import { formatTimeToRelative } from '#/util/tool';
|
||||||
import {formatTimeToRelative} from "#/util/tool";
|
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||||
import {notification} from "ant-design-vue";
|
import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue';
|
||||||
|
import {
|
||||||
|
emitNoticeRefresh,
|
||||||
|
onNoticeRefresh,
|
||||||
|
openNoticeAction,
|
||||||
|
} from '#/views/notice/action/bus';
|
||||||
|
import { resolveNoticeColor } from '#/views/notice/action/types';
|
||||||
|
import {
|
||||||
|
getNoticeListApi,
|
||||||
|
getNoticeUnreadCountApi,
|
||||||
|
readAllApi,
|
||||||
|
readApi,
|
||||||
|
} from '#/views/notice/api';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
|
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
|
||||||
|
|
||||||
// 样式相关
|
|
||||||
const typeIcons = {
|
|
||||||
0: Megaphone,
|
|
||||||
1: Bell,
|
|
||||||
2: CalendarClock,
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeColors = {
|
|
||||||
0: 'text-sky-500',
|
|
||||||
1: 'text-amber-500',
|
|
||||||
2: 'text-emerald-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeBgColors = {
|
|
||||||
0: 'bg-sky-50',
|
|
||||||
1: 'bg-amber-50',
|
|
||||||
2: 'bg-emerald-50',
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeBorderColors = {
|
|
||||||
0: 'border-sky-200',
|
|
||||||
1: 'border-amber-200',
|
|
||||||
2: 'border-emerald-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
const notifications = ref<NotificationItem[]>([]);
|
const notifications = ref<NotificationItem[]>([]);
|
||||||
|
const unreadCount = ref(0);
|
||||||
|
|
||||||
// 获取消息列表数据
|
/** 拉取未读列表 + 角标数字 */
|
||||||
const getNoticeList = async () => {
|
const getNoticeList = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getNoticeListApi({
|
const [res, countRes] = await Promise.all([
|
||||||
status: 0,
|
getNoticeListApi({ status: 0, page: 1, pageSize: 20 }),
|
||||||
});
|
getNoticeUnreadCountApi().catch(() => null),
|
||||||
let message = '未读消息';
|
]);
|
||||||
if (res.items.length > 0) {
|
const items = res?.items || [];
|
||||||
if (res.items.length > notifications.value.length) {
|
const prevLen = notifications.value.length;
|
||||||
if (notifications.value.length === 0) {
|
if (items.length > prevLen && prevLen > 0) {
|
||||||
message = '新消息';
|
notification.info({
|
||||||
}
|
message: '您有新消息',
|
||||||
notification.info({
|
description: `您有 ${items.length} 条未读消息请注意查收`,
|
||||||
message: `您有${message}`,
|
duration: 5,
|
||||||
description: `您有 ${res.items.length} 条${message}请注意查收`,
|
});
|
||||||
duration: 5,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.value = res.items.map(item => ({
|
|
||||||
id: item.id,
|
|
||||||
icon: typeIcons[item.type],
|
|
||||||
// 将API返回的数据映射到组件需要的格式
|
|
||||||
message: item.title,
|
|
||||||
title: getMessageTypeName(item.type),
|
|
||||||
date: formatTimeToRelative(item.created_at),
|
|
||||||
color: typeColors[item.type],
|
|
||||||
isRead: item.status
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
notifications.value = [];
|
|
||||||
}
|
}
|
||||||
|
notifications.value = items.map((item: any) => {
|
||||||
|
const color = resolveNoticeColor(item.type_color);
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
// Notification 默认用组件当 icon;我们走自定义 content 槽,这里占位即可
|
||||||
|
icon: Bell as any,
|
||||||
|
message: item.detail || item.type_name || '',
|
||||||
|
title: item.title || '通知',
|
||||||
|
date: formatTimeToRelative(item.created_at),
|
||||||
|
color: color.text,
|
||||||
|
colorBg: color.bg,
|
||||||
|
isRead: Number(item.status) === 1,
|
||||||
|
status: item.status,
|
||||||
|
type_code: item.type_code,
|
||||||
|
type_name: item.type_name,
|
||||||
|
type_icon: item.type_icon,
|
||||||
|
type_color: item.type_color,
|
||||||
|
action_type: item.action_type,
|
||||||
|
action_payload: item.action_payload,
|
||||||
|
detail: item.detail,
|
||||||
|
content: item.content,
|
||||||
|
edit_type: item.edit_type,
|
||||||
|
priority: item.priority,
|
||||||
|
raw: item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
unreadCount.value = Number(
|
||||||
|
countRes?.count ?? res?.unread_total ?? items.length,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取消息列表失败:', error);
|
console.error('获取消息列表失败:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
getNoticeList();
|
getNoticeList();
|
||||||
// 每隔二十秒刷新一次
|
|
||||||
let intervalId = setInterval(getNoticeList, 20_000);
|
let intervalId = setInterval(getNoticeList, 20_000);
|
||||||
|
|
||||||
// 封装定时器重启逻辑
|
|
||||||
const restartInterval = () => {
|
const restartInterval = () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
getNoticeList(); // 立即获取最新数据
|
getNoticeList();
|
||||||
intervalId = setInterval(getNoticeList, 20_000);
|
intervalId = setInterval(getNoticeList, 20_000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理跨标签页修改
|
const handleStorageChange = (event: StorageEvent) => {
|
||||||
const handleStorageChange = (event) => {
|
|
||||||
if (event.key === 'readStatus') {
|
if (event.key === 'readStatus') {
|
||||||
restartInterval();
|
restartInterval();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理当前标签页修改(核心新增部分)
|
|
||||||
const handleLocalChange = () => {
|
|
||||||
restartInterval();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加双重监听
|
|
||||||
window.addEventListener('storage', handleStorageChange); // 跨标签页监听
|
|
||||||
const originalSetItem = localStorage.setItem.bind(localStorage);
|
const originalSetItem = localStorage.setItem.bind(localStorage);
|
||||||
|
|
||||||
// 拦截当前页的 localStorage.setItem 操作
|
|
||||||
localStorage.setItem = (key, value) => {
|
localStorage.setItem = (key, value) => {
|
||||||
originalSetItem(key, value);
|
originalSetItem(key, value);
|
||||||
if (key === 'readStatus') {
|
if (key === 'readStatus') {
|
||||||
handleLocalChange(); // 手动触发当前页回调
|
restartInterval();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 组件卸载时清理
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
const stopRefreshListen = onNoticeRefresh(() => restartInterval());
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
window.removeEventListener('storage', handleStorageChange);
|
window.removeEventListener('storage', handleStorageChange);
|
||||||
localStorage.setItem = originalSetItem; // 恢复原始方法
|
localStorage.setItem = originalSetItem;
|
||||||
|
stopRefreshListen();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 消息类型定义
|
|
||||||
const messageTypes = [
|
|
||||||
{ id: -1, name: '全部消息' },
|
|
||||||
{ id: 0, name: '系统公告', icon: Megaphone },
|
|
||||||
{ id: 1, name: '系统通知', icon: Bell },
|
|
||||||
{ id: 2, name: '周报提醒', icon: CalendarClock },
|
|
||||||
];
|
|
||||||
// 根据类型ID获取类型名称
|
|
||||||
const getMessageTypeName = (typeId) => {
|
|
||||||
const type = messageTypes.find(t => t.id === typeId);
|
|
||||||
return type ? type.name : '未知类型';
|
|
||||||
};
|
|
||||||
const goNoticeList = () => {
|
const goNoticeList = () => {
|
||||||
// TODO 跳转通知列表
|
|
||||||
router.push('/notice/list');
|
router.push('/notice/list');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理消息点击
|
/** 从铃铛列表移除并扣减角标 */
|
||||||
const handleItemClick = (item) => {
|
function removeFromBell(item: NotificationItem) {
|
||||||
notifications.value.forEach((value) => {
|
notifications.value = notifications.value.filter((n) => n.id !== item.id);
|
||||||
if (value.id === item.id) {
|
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||||
value.status = 1;
|
}
|
||||||
value.isRead = 1;
|
|
||||||
}
|
/** 行点击:立刻取消未读并打开场景 */
|
||||||
|
const handleItemClick = async (item: NotificationItem) => {
|
||||||
|
const raw = (item as any).raw || item;
|
||||||
|
const wasUnread = !item.isRead;
|
||||||
|
item.isRead = true;
|
||||||
|
if (wasUnread) {
|
||||||
|
removeFromBell(item);
|
||||||
|
}
|
||||||
|
await openNoticeAction({
|
||||||
|
...raw,
|
||||||
|
id: item.id,
|
||||||
|
status: wasUnread ? 0 : 1,
|
||||||
});
|
});
|
||||||
router.push(`/notice/detail/${item.id}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClick = async (item: NotificationItem) => {
|
||||||
|
await handleItemClick(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 勾选已读:只标已读,不打开详情 */
|
||||||
|
async function handleMarkRead(item: NotificationItem) {
|
||||||
|
if (item.isRead) return;
|
||||||
|
item.isRead = true;
|
||||||
|
removeFromBell(item);
|
||||||
|
try {
|
||||||
|
await readApi(Number(item.id));
|
||||||
|
emitNoticeRefresh();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||||
const showDot = computed(() =>
|
const { isDark } = usePreferences();
|
||||||
notifications.value.some((item) => !item.isRead),
|
const showDot = computed(() => unreadCount.value > 0);
|
||||||
);
|
|
||||||
|
const showDoctorTransfer = computed(() => {
|
||||||
|
const info = userStore.userInfo as Record<string, any> | null | undefined;
|
||||||
|
if (!info) return false;
|
||||||
|
return !!(info.doctor_id && info.store_id);
|
||||||
|
});
|
||||||
|
|
||||||
const menus = computed(() => [
|
const menus = computed(() => [
|
||||||
|
{
|
||||||
|
handler: () => {
|
||||||
|
router.push({ name: 'Profile' });
|
||||||
|
},
|
||||||
|
icon: 'lucide:user',
|
||||||
|
text: $t('page.auth.profile'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
handler: () => {
|
handler: () => {
|
||||||
switchAccountModalRef.value?.openModal();
|
switchAccountModalRef.value?.openModal();
|
||||||
@@ -201,7 +213,20 @@ async function handleLogout() {
|
|||||||
function handleNoticeClear() {
|
function handleNoticeClear() {
|
||||||
readAllApi().then(() => {
|
readAllApi().then(() => {
|
||||||
notifications.value = [];
|
notifications.value = [];
|
||||||
// 成功后可以重新获取数据或者直接更新本地状态
|
unreadCount.value = 0;
|
||||||
|
notification.success({
|
||||||
|
message: '标记成功',
|
||||||
|
duration: 2,
|
||||||
|
});
|
||||||
|
emitNoticeRefresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部已读:必须走后端 API */
|
||||||
|
function handleMakeAll() {
|
||||||
|
readAllApi().then(() => {
|
||||||
|
notifications.value = [];
|
||||||
|
unreadCount.value = 0;
|
||||||
notification.success({
|
notification.success({
|
||||||
message: '标记成功',
|
message: '标记成功',
|
||||||
duration: 2,
|
duration: 2,
|
||||||
@@ -209,49 +234,100 @@ function handleNoticeClear() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMakeAll() {
|
|
||||||
notifications.value.forEach((item) => (item.isRead = true));
|
|
||||||
}
|
|
||||||
watch(
|
watch(
|
||||||
() => preferences.app.watermark,
|
() => ({
|
||||||
async (enable) => {
|
enable: preferences.app.watermark,
|
||||||
|
isDark: isDark.value,
|
||||||
|
}),
|
||||||
|
async ({ enable }) => {
|
||||||
if (enable) {
|
if (enable) {
|
||||||
await updateWatermark({
|
await updateWatermark({
|
||||||
// 这里更改水印内容
|
|
||||||
// content: `${userStore.userInfo?.nick_name || import.meta.env.VITE_APP_TITLE}`,
|
|
||||||
content: `${userStore.userInfo?.nick_name}\r\n${userStore.userInfo?.phone || import.meta.env.VITE_APP_TITLE}`,
|
content: `${userStore.userInfo?.nick_name}\r\n${userStore.userInfo?.phone || import.meta.env.VITE_APP_TITLE}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
destroyWatermark();
|
destroyWatermark();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{ immediate: true },
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
<BasicLayout
|
||||||
|
:avatar
|
||||||
|
:text="userStore.userInfo?.realName"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
|
@logout="handleLogout"
|
||||||
|
>
|
||||||
|
<template #header-right-55>
|
||||||
|
<DoctorTransferFloat v-if="showDoctorTransfer" />
|
||||||
|
</template>
|
||||||
<template #user-dropdown>
|
<template #user-dropdown>
|
||||||
<UserDropdown
|
<!-- VIP 徽标放在头像左侧,与用户下拉并排 -->
|
||||||
:avatar
|
<div class="flex items-center">
|
||||||
:description="userStore.userInfo?.email"
|
<HeaderVipBadge />
|
||||||
:menus
|
<UserDropdown
|
||||||
:tag-text="userStore.userInfo?.roles?.name"
|
:avatar
|
||||||
:text="userStore.userInfo?.nick_name"
|
:menus
|
||||||
@logout="handleLogout"
|
:text="userStore.userInfo?.nick_name"
|
||||||
/>
|
:description="userStore.userInfo?.email"
|
||||||
|
:tag-text="userStore.userInfo?.roles?.name"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
|
@logout="handleLogout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #notification>
|
<template #notification>
|
||||||
<Notification
|
<Notification
|
||||||
:dot="showDot"
|
:dot="showDot"
|
||||||
|
:count="unreadCount"
|
||||||
:notifications="notifications"
|
:notifications="notifications"
|
||||||
@clear="handleNoticeClear"
|
@clear="handleNoticeClear"
|
||||||
@make-all="handleMakeAll"
|
@make-all="handleMakeAll"
|
||||||
@read="handleItemClick"
|
@read="handleMarkRead"
|
||||||
@view-all="goNoticeList"
|
@view-all="goNoticeList"
|
||||||
/>
|
@on-click="handleClick"
|
||||||
|
>
|
||||||
|
<template #content="{ item }">
|
||||||
|
<span
|
||||||
|
v-if="!item.isRead"
|
||||||
|
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl"
|
||||||
|
:class="[(item as any).colorBg || 'bg-muted', (item as any).color]"
|
||||||
|
>
|
||||||
|
<VbenIcon
|
||||||
|
v-if="(item as any).type_icon"
|
||||||
|
:icon="(item as any).type_icon"
|
||||||
|
class="size-5"
|
||||||
|
/>
|
||||||
|
<Bell v-else class="size-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1 pr-8 leading-none">
|
||||||
|
<div class="mb-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="(item as any).type_name"
|
||||||
|
class="truncate text-[11px] font-semibold"
|
||||||
|
:class="(item as any).color"
|
||||||
|
>
|
||||||
|
{{ (item as any).type_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="truncate text-sm font-semibold text-foreground">
|
||||||
|
{{ item.title }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="item.message"
|
||||||
|
class="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ item.message }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">{{ item.date }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Notification>
|
||||||
|
<NoticeActionHost />
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<AuthenticationLoginExpiredModal
|
<AuthenticationLoginExpiredModal
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user