Compare commits
1161 Commits
feature/in
...
vben-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d8a328db2 | ||
|
|
b7a70ea113 | ||
|
|
02672b16bd | ||
|
|
879efb8f29 | ||
|
|
66e666754a | ||
|
|
b685257a93 | ||
|
|
d159e514b2 | ||
|
|
c6d2b0f917 | ||
| 4ca7479550 | |||
| a159739fa2 | |||
|
|
beba5e8feb | ||
|
|
f15bf10553 | ||
|
|
f622743661 | ||
|
|
6db736a6f4 | ||
|
|
b82754e36b | ||
|
|
f0a33a628b | ||
|
|
3547c53f89 | ||
|
|
82b10bd122 | ||
|
|
e8d35a7471 | ||
|
|
f038166681 | ||
|
|
88cc837f6a | ||
|
|
82d790a6ab | ||
|
|
9c3ba53990 | ||
|
|
cb707c8c78 | ||
|
|
98761dce98 | ||
|
|
d3dc081b5d | ||
|
|
2c1e643ab9 | ||
|
|
0a47dc8a3d | ||
|
|
d0c8c887fd | ||
|
|
f16c780c2b | ||
|
|
ebe0eadee4 | ||
|
|
3cbb1e8411 | ||
|
|
21d9132703 | ||
|
|
9f5b1cd9fb | ||
|
|
32495e40b2 | ||
|
|
cb52807b27 | ||
|
|
418c16e093 | ||
|
|
b04d728249 | ||
|
|
44483db9af | ||
|
|
fbf0cebf4b | ||
|
|
98344dfcec | ||
|
|
6d5ccc7ac8 | ||
|
|
b9c6d56f74 | ||
|
|
aaf0da582c | ||
|
|
9a5d052fa5 | ||
|
|
baea741432 | ||
|
|
33f4522bff | ||
|
|
cb6b0a8872 | ||
|
|
57e2a54219 | ||
|
|
4db254cd21 | ||
|
|
255a76d79a | ||
|
|
dcdbfe5dfc | ||
|
|
0542b509ca | ||
|
|
eaf585d03a | ||
|
|
0a45cb41fb | ||
|
|
517a42ec05 | ||
|
|
bacfdf5e42 | ||
|
|
2e69c3fefc | ||
|
|
43b78da024 | ||
|
|
decbed20bc | ||
|
|
1ac5ac3e82 | ||
|
|
8d5a419c50 | ||
|
|
2d82505330 | ||
|
|
98dea92225 | ||
|
|
e6a23479ee | ||
|
|
294e9f2966 | ||
|
|
f43de252b5 | ||
|
|
ebc0f23e64 | ||
|
|
28757fb9c8 | ||
|
|
81f69d797d | ||
|
|
6b6708bcf2 | ||
|
|
5c1d31f680 | ||
|
|
899910547f | ||
|
|
70a1d0a2e0 | ||
|
|
24eb67224d | ||
|
|
e4e25028b5 | ||
|
|
ef27b4e349 | ||
|
|
b21563a89b | ||
|
|
d06451cdb7 | ||
|
|
5318774efe | ||
|
|
68a2717a26 | ||
|
|
0a2c01e453 | ||
|
|
b330f08ef4 | ||
|
|
6676d962e9 | ||
|
|
dd8307dfe5 | ||
|
|
d0c327e037 | ||
|
|
901a147abb | ||
|
|
86e68bdde3 | ||
|
|
054deae3bb | ||
|
|
772286fb73 | ||
|
|
5936ff2e0f | ||
|
|
f840d235ef | ||
|
|
930597c605 | ||
|
|
41c20a43af | ||
|
|
fe65d548be | ||
|
|
0cd87c170f | ||
|
|
b71ebbd673 | ||
|
|
31d3bd18f3 | ||
|
|
eae6771554 | ||
|
|
8ac66a96bc | ||
|
|
822cec0f5c | ||
|
|
6910c691f7 | ||
|
|
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
|
||||||
@@ -10,6 +10,7 @@ alwaysApply: true
|
|||||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||||
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show)
|
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show)
|
||||||
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
||||||
|
6. 卡片强调样式:**禁止**用左侧竖色条(`border-left: Npx solid 色值` / 左侧色块)做卡片强调或分类标识;统一用「1px 同色系边框 + 微光」——`border: 1px solid hsl(var(--主题色) / 30%)` 配 `box-shadow: 0 0 6px hsl(var(--主题色) / 18%)`;历史页面暂不强制回改,新增/改动的卡片必须遵守
|
||||||
|
|
||||||
# 全局架构规范
|
# 全局架构规范
|
||||||
|
|
||||||
@@ -67,9 +68,54 @@ alwaysApply: true
|
|||||||
|
|
||||||
## 表单校验
|
## 表单校验
|
||||||
|
|
||||||
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules`
|
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `rules`
|
||||||
- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })`
|
|
||||||
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
|
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
|
||||||
|
- 校验调用统一用 **单次 await**(禁止 `.then` 与 `validateAndSubmitForm` 并用):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const e = await formApi.validate();
|
||||||
|
if (!e.valid) return;
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 弹窗 onConfirm(强制,防「点确定无反应」)
|
||||||
|
|
||||||
|
弹窗内提交必须按下列写法,**禁止**复制旧模板:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
onConfirm: async () => {
|
||||||
|
const e = await formApi.validate();
|
||||||
|
if (!e.valid) return;
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
modalApi.setState({ loading: true, confirmLoading: true });
|
||||||
|
try {
|
||||||
|
await submitApi(values);
|
||||||
|
message.success('保存成功');
|
||||||
|
gridApi.value?.reload?.();
|
||||||
|
modalApi.close();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ loading: false, confirmLoading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- **禁止** `formApi.validate().then(...)` 后再 `await formApi.validateAndSubmitForm()`(并发二次校验,表现为点确定无提交)
|
||||||
|
- **禁止**无 `handleSubmit` 时调用 `validateAndSubmitForm` / `validateAndSubmit`
|
||||||
|
- 参考:`product-order/components/refund.vue`、`audit-prescription/components/modal.vue`、`delivery-warehouse-order/components/modal.vue`
|
||||||
|
|
||||||
|
## 表单 Divider / 分区标题
|
||||||
|
|
||||||
|
- `component: 'Divider'` 仅作分区展示,**禁止**加 `rules: 'required'`(无业务值,必失败)
|
||||||
|
- **禁止**用占位 `label: 'x'`(会渲染成校验文案「请输入x」)
|
||||||
|
- `fieldName` 用唯一占位如 `_section_basic`,不要空字符串
|
||||||
|
|
||||||
|
## 敏感字段展示(SensitiveText)
|
||||||
|
|
||||||
|
- 模板使用 `<SensitiveText>` 时,**必须**显式:`import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'`
|
||||||
|
- 该组件**未**全局注册;表单选择器(`form/components/*-picker.vue`)同样要 import,对齐 `doctor-picker` / `store-picker`
|
||||||
|
- **禁止**只写模板标签不写 import(会报 `Failed to resolve component: SensitiveText`)
|
||||||
|
|
||||||
## 字典/枚举管理
|
## 字典/枚举管理
|
||||||
|
|
||||||
@@ -176,10 +222,10 @@ 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.reset()`(`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()`
|
- 校验用 `const e = await formApi.validate(); if (!e.valid) return;`,**禁止** `validate().then` 与 `validateAndSubmitForm` 并用(见上文「弹窗 onConfirm」)
|
||||||
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
|
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `rules` 注册
|
||||||
- schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase
|
- schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase
|
||||||
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
|
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
|
||||||
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
|
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
|
||||||
|
|||||||
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
|
||||||
|
|||||||
17
.npmrc
17
.npmrc
@@ -1,13 +1,4 @@
|
|||||||
registry = "https://registry.npmmirror.com"
|
registry=https://registry.npmmirror.com
|
||||||
public-hoist-pattern[]=husky
|
# Electron 二进制与 electron-builder 打包工具链的国内镜像(不配置在国内网络下大概率下载超时失败)
|
||||||
public-hoist-pattern[]=eslint
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
public-hoist-pattern[]=prettier
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
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
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -5,3 +5,13 @@
|
|||||||
```vue
|
```vue
|
||||||
v-access:code="['Admin']
|
v-access:code="['Admin']
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Web + 桌面一起打
|
||||||
|
pnpm build:antd-desktop
|
||||||
|
|
||||||
|
# 只打 Web
|
||||||
|
node build-antd-desktop.mjs --only=web
|
||||||
|
|
||||||
|
# 只打桌面(需已有 apps/web-antd/dist)
|
||||||
|
node build-antd-desktop.mjs --only=desktop
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
2
apps/desktop/.gitignore
vendored
Normal file
2
apps/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
out/
|
||||||
|
release/
|
||||||
49
apps/desktop/README.md
Normal file
49
apps/desktop/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# @vben/desktop — 萧康云医 PC 桌面端
|
||||||
|
|
||||||
|
Electron 壳工程:打包加载 `apps/web-antd` 的生产构建产物,提供桌面通知、开机自启、系统托盘、未读角标、静默打印、全局快捷键与自动更新能力。
|
||||||
|
|
||||||
|
## 常用命令(在仓库根目录执行)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发:并行启动 web-antd dev server(:5666)与 Electron 壳(壳会自动等待/重试连接 dev server)
|
||||||
|
pnpm dev:desktop
|
||||||
|
|
||||||
|
# 打包:turbo 先构建 web-antd dist,再构建主进程并出安装包(产物在 apps/desktop/release/)
|
||||||
|
pnpm build:desktop
|
||||||
|
|
||||||
|
# 一键同时打 Web + 桌面(根目录 build-antd-desktop.mjs,先 web 再桌面)
|
||||||
|
pnpm build:antd-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
- Windows 机器出 NSIS 安装包;mac 包必须在 macOS 机器上执行同一命令(electron-builder 不支持交叉编译 mac 包)。
|
||||||
|
- 不需要打桌面包的同事 / CI,可设置环境变量 `ELECTRON_SKIP_BINARY_DOWNLOAD=1` 跳过 Electron 二进制下载,不影响 web 开发。
|
||||||
|
|
||||||
|
## 环境切换(dev 走本地,打包走生产)
|
||||||
|
|
||||||
|
| 模式 | 页面来源 | API 走向 |
|
||||||
|
|---|---|---|
|
||||||
|
| `pnpm dev:desktop` | `http://localhost:5666`(web-antd vite dev server) | vite 代理转发到**本地后端**:`/api/*` → `localhost:18001`、`/im-api/*` → `localhost:12080`(见 `apps/web-antd/vite.config.ts`) |
|
||||||
|
| 打包后的安装包 | `app://local/`(内置 web-antd 生产 dist) | 主进程协议处理器转发到**生产上游**:`/api/admin/*` → `api.xiaokang88.com`、`/im-api/*` → `api.ws.g.xiaokang88.com/api`(见 `src/main/config.ts`,可用环境变量覆盖) |
|
||||||
|
|
||||||
|
即:开发调试自动对接本地后端(与网页开发完全一致),只有 `pnpm build:desktop` 出的安装包才请求生产环境,无需手动切换。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `XK_DESKTOP_API_UPSTREAM` | 覆盖 `/api/admin` 转发上游(联调测试环境用) |
|
||||||
|
| `XK_DESKTOP_IM_UPSTREAM` | 覆盖 `/im-api` 转发上游 |
|
||||||
|
| `XK_DESKTOP_DEV_URL` | 覆盖开发模式加载的 dev server 地址(默认 `http://localhost:5666`) |
|
||||||
|
| `CSC_LINK` / `CSC_KEY_PASSWORD` | 代码签名证书;**未配置时自动跳过签名,出未签名包,构建不报错** |
|
||||||
|
| `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` | mac 公证三件套;三者齐全才启用公证 |
|
||||||
|
|
||||||
|
未签名 mac 包可内部分发:首次打开需右键「打开」,且 mac 自动更新不可用(应用内会降级为提示前往下载页手动安装)。
|
||||||
|
|
||||||
|
## 发布自动更新
|
||||||
|
|
||||||
|
`release/` 里的以下产物上传到静态托管地址(`electron-builder.config.cjs` 的 `publish.url`):
|
||||||
|
|
||||||
|
- Windows:`latest.yml` + `xk-desktop-*.exe` + `*.blockmap`
|
||||||
|
- mac:`latest-mac.yml` + `*.dmg` + `*.zip` + `*.blockmap`(zip 是 mac 自动更新必需格式)
|
||||||
|
|
||||||
|
前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,桌面端即可自动更新。
|
||||||
78
apps/desktop/electron-builder.config.cjs
Normal file
78
apps/desktop/electron-builder.config.cjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* electron-builder 打包配置
|
||||||
|
* 用 JS 配置而不是 yml:mac 公证需要按「环境变量是否配齐」动态开关。
|
||||||
|
*
|
||||||
|
* 签名策略(按需求做成条件化,未配置证书时出未签名包且构建不报错):
|
||||||
|
* - Windows / mac 代码签名:配置了 CSC_LINK + CSC_KEY_PASSWORD(或 mac 钥匙串里有
|
||||||
|
* Developer ID 证书)时 electron-builder 自动签名;否则自动跳过,只输出告警
|
||||||
|
* - mac 公证:APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID 三者齐全才启用
|
||||||
|
* - 未签名 mac 包仍可内部分发:首次打开需右键「打开」,且自动更新会降级为提示手动下载
|
||||||
|
*/
|
||||||
|
const canNotarize = Boolean(
|
||||||
|
process.env.APPLE_ID &&
|
||||||
|
process.env.APPLE_APP_SPECIFIC_PASSWORD &&
|
||||||
|
process.env.APPLE_TEAM_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @type {import('electron-builder').Configuration} */
|
||||||
|
const config = {
|
||||||
|
// appId 必须与主进程 setAppUserModelId 一致,否则 Windows 系统通知不显示
|
||||||
|
appId: 'com.xiaokang.xkadmin',
|
||||||
|
productName: '萧康云医',
|
||||||
|
// 安装包文件名用 ASCII,避免中文名在静态托管/下载链接上出编码问题
|
||||||
|
artifactName: 'xk-desktop-${version}-${os}-${arch}.${ext}',
|
||||||
|
directories: {
|
||||||
|
output: 'release',
|
||||||
|
buildResources: 'resources',
|
||||||
|
},
|
||||||
|
files: ['out/**'],
|
||||||
|
extraResources: [
|
||||||
|
// web-antd 生产构建产物:放 resources/web(不进 asar),为后续渲染层热更新留路
|
||||||
|
{ from: '../web-antd/dist', to: 'web' },
|
||||||
|
// 应用图标供运行时使用(托盘、Windows 窗口图标)
|
||||||
|
{ from: 'resources/icon.png', to: 'icon.png' },
|
||||||
|
],
|
||||||
|
asar: true,
|
||||||
|
compression: 'normal',
|
||||||
|
win: {
|
||||||
|
// TODO(待确认):正式图标待提供,当前用 web-antd logo 预生成的 ico 占位
|
||||||
|
// 直接给 .ico 让 builder 跳过 png 转换(本机 builder 的 wasm 图标工具会报内存错误)
|
||||||
|
icon: 'resources/icon.ico',
|
||||||
|
target: [{ target: 'nsis', arch: ['x64'] }],
|
||||||
|
},
|
||||||
|
nsis: {
|
||||||
|
// 非一键安装:允许用户选择安装目录,符合企业内部软件分发习惯
|
||||||
|
oneClick: false,
|
||||||
|
perMachine: false,
|
||||||
|
allowToChangeInstallationDirectory: true,
|
||||||
|
createDesktopShortcut: true,
|
||||||
|
createStartMenuShortcut: true,
|
||||||
|
shortcutName: '萧康云医',
|
||||||
|
},
|
||||||
|
mac: {
|
||||||
|
icon: 'resources/icon.png',
|
||||||
|
category: 'public.app-category.medical',
|
||||||
|
// dmg 给用户安装,zip 是 electron-updater 在 mac 自动更新的必需格式,缺一不可
|
||||||
|
target: [
|
||||||
|
{ target: 'dmg', arch: ['x64', 'arm64'] },
|
||||||
|
{ target: 'zip', arch: ['x64', 'arm64'] },
|
||||||
|
],
|
||||||
|
hardenedRuntime: true,
|
||||||
|
gatekeeperAssess: false,
|
||||||
|
entitlements: 'resources/entitlements.mac.plist',
|
||||||
|
entitlementsInherit: 'resources/entitlements.mac.plist',
|
||||||
|
// IM 音视频通话必需的隐私用途描述,缺失时 mac 上调用摄像头/麦克风直接崩溃
|
||||||
|
extendInfo: {
|
||||||
|
NSCameraUsageDescription: '视频问诊需要使用摄像头',
|
||||||
|
NSMicrophoneUsageDescription: '语音/视频通话需要使用麦克风',
|
||||||
|
},
|
||||||
|
notarize: canNotarize,
|
||||||
|
},
|
||||||
|
publish: {
|
||||||
|
provider: 'generic',
|
||||||
|
// TODO(待确认):更新包静态托管地址,需与主进程 config.ts 的 DOWNLOAD_PAGE_URL 保持一致
|
||||||
|
url: 'https://static.xiaokang88.com/xk-desktop/',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
26
apps/desktop/electron.vite.config.ts
Normal file
26
apps/desktop/electron.vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* electron-vite 构建配置
|
||||||
|
* 只构建主进程与预加载脚本,不配置 renderer:
|
||||||
|
* - 开发模式渲染层直接加载 web-antd 的 vite dev server(http://localhost:5666)
|
||||||
|
* - 打包模式渲染层加载 app://local/(web-antd 的生产 dist,由主进程自定义协议提供服务)
|
||||||
|
* externalizeDepsPlugin:dependencies(electron-updater / electron-log)保持外部化,
|
||||||
|
* 由 electron-builder 按生产依赖收集进安装包,避免打包器转译这类含运行时文件读取的库出问题
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/main',
|
||||||
|
lib: { entry: 'src/main/index.ts' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/preload',
|
||||||
|
lib: { entry: 'src/preload/index.ts' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
27
apps/desktop/package.json
Normal file
27
apps/desktop/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@vben/desktop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)",
|
||||||
|
"author": "萧康云医",
|
||||||
|
"main": "out/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"pack": "electron-builder --config electron-builder.config.cjs",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-log": "catalog:",
|
||||||
|
"electron-updater": "^6.8.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"@vben/web-antd": "workspace:*",
|
||||||
|
"electron": "^43.4.0",
|
||||||
|
"electron-builder": "catalog:",
|
||||||
|
"electron-vite": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/desktop/resources/entitlements.mac.plist
Normal file
18
apps/desktop/resources/entitlements.mac.plist
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- hardenedRuntime 下 Electron(V8 JIT)运行必需的内存权限 -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
<!-- IM 音视频通话:摄像头 / 麦克风访问权限 -->
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
apps/desktop/resources/icon.ico
Normal file
BIN
apps/desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
apps/desktop/resources/icon.png
Normal file
BIN
apps/desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
30
apps/desktop/src/main/auto-launch.ts
Normal file
30
apps/desktop/src/main/auto-launch.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开机自启:基于系统登录项实现(Windows 写 HKCU\...\Run 注册表、mac 写「系统设置 → 登录项」),
|
||||||
|
* 无需管理员权限;系统登录项本身就是持久化存储,无需额外落盘记录状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 读取当前开机自启状态(直接以系统登录项为准,避免自维护状态与系统不同步) */
|
||||||
|
export function getAutoLaunchEnabled(): boolean {
|
||||||
|
// 开发模式下登录项指向 electron 开发二进制,无意义,统一视为关闭
|
||||||
|
if (!app.isPackaged) return false;
|
||||||
|
return app.getLoginItemSettings().openAtLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置开机自启
|
||||||
|
* --hidden 参数让开机自启时静默进托盘(主进程入口据此不弹主窗口),避免开机打扰
|
||||||
|
*/
|
||||||
|
export function setAutoLaunchEnabled(enable: boolean): boolean {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
log.warn('开发模式不支持设置开机自启(登录项会错误指向开发二进制)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: enable,
|
||||||
|
args: enable ? ['--hidden'] : [],
|
||||||
|
});
|
||||||
|
return getAutoLaunchEnabled();
|
||||||
|
}
|
||||||
24
apps/desktop/src/main/badge.ts
Normal file
24
apps/desktop/src/main/badge.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { app, nativeImage } from 'electron';
|
||||||
|
|
||||||
|
import { getMainWindow } from './window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未读角标:按平台分支实现
|
||||||
|
* - mac:Dock 图标原生支持文字角标,直接 setBadge
|
||||||
|
* - Windows:任务栏无原生数字角标,用「覆盖图标」实现(图由 preload 侧 canvas 绘制成 dataURL 传来,
|
||||||
|
* 因为主进程没有 DOM/canvas,在渲染侧画图是社区标准做法)
|
||||||
|
*/
|
||||||
|
export function applyBadge(count: number, image?: string): void {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
app.dock?.setBadge(count > 0 ? String(Math.min(count, 99)) : '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const win = getMainWindow();
|
||||||
|
if (!win) return;
|
||||||
|
if (count > 0 && image) {
|
||||||
|
win.setOverlayIcon(nativeImage.createFromDataURL(image), `${count} 条未读消息`);
|
||||||
|
} else {
|
||||||
|
// 数量归零或没有角标图时清除覆盖图标
|
||||||
|
win.setOverlayIcon(null, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/desktop/src/main/config.ts
Normal file
54
apps/desktop/src/main/config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面端全局配置
|
||||||
|
* 打包模式下渲染层加载的是本地 dist,所有「按环境调整」的地址都收敛在这里,
|
||||||
|
* 并支持环境变量覆盖,便于联调测试环境时不改代码切换上游。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 应用标识:必须与 electron-builder 的 appId 一致,否则 Windows 系统通知不显示 */
|
||||||
|
export const APP_ID = 'com.xiaokang.xkadmin';
|
||||||
|
|
||||||
|
/** 自定义协议,注册为特权协议后 app://local/ 等价一个「本地 https 站点」(安全上下文) */
|
||||||
|
export const APP_SCHEME = 'app';
|
||||||
|
export const APP_HOST = 'local';
|
||||||
|
export const APP_ORIGIN = `${APP_SCHEME}://${APP_HOST}`;
|
||||||
|
export const APP_INDEX_URL = `${APP_ORIGIN}/`;
|
||||||
|
|
||||||
|
/** 开发模式加载 web-antd 的 vite dev server(端口对齐 apps/web-antd/.env.development 的 VITE_PORT=5666) */
|
||||||
|
export const DEV_SERVER_URL = process.env.XK_DESKTOP_DEV_URL ?? 'http://localhost:5666';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/admin/* 的转发上游(仅打包后的 app:// 协议使用;dev 模式加载 vite dev server,
|
||||||
|
* 由 vite 代理转发到本地后端 localhost:18001,不经过这里)
|
||||||
|
* 地址来源:apps/web-antd/vite.config.ts 代理注释中的生产目标 https://api.xiaokang88.com/api/admin/
|
||||||
|
*/
|
||||||
|
export const API_UPSTREAM = process.env.XK_DESKTOP_API_UPSTREAM ?? 'https://api.xiaokang88.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /im-api/* 的转发上游(转发规则对齐开发代理:/im-api/* → 上游 /api/*;同样仅打包后生效,
|
||||||
|
* dev 模式由 vite 代理转发到本地 IM localhost:12080)
|
||||||
|
* 地址来源:vite.config.ts 代理注释的生产目标 https://api.ws.g.xiaokang88.com/api/,与生产 WS 同主机
|
||||||
|
*/
|
||||||
|
export const IM_UPSTREAM = process.env.XK_DESKTOP_IM_UPSTREAM ?? 'https://api.ws.g.xiaokang88.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新包下载页地址(与 electron-builder.config.cjs 的 publish.url 保持一致)
|
||||||
|
* 兼作 mac 未签名时「自动更新不可用 → 手动下载」的跳转页
|
||||||
|
* TODO(待确认):更新包静态托管地址
|
||||||
|
*/
|
||||||
|
export const DOWNLOAD_PAGE_URL = 'https://static.xiaokang88.com/xk-desktop/';
|
||||||
|
|
||||||
|
/** 全局快捷键:唤起/隐藏主窗口(CommandOrControl 在 Windows 映射 Ctrl、mac 映射 Cmd) */
|
||||||
|
export const MAIN_WINDOW_SHORTCUT = 'CommandOrControl+Alt+X';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析运行时资源路径(图标等)
|
||||||
|
* 打包后 extraResources 位于 process.resourcesPath,开发时直接读工程 resources 目录
|
||||||
|
*/
|
||||||
|
export function getResourcePath(name: string): string {
|
||||||
|
return app.isPackaged
|
||||||
|
? join(process.resourcesPath, name)
|
||||||
|
: join(app.getAppPath(), 'resources', name);
|
||||||
|
}
|
||||||
81
apps/desktop/src/main/index.ts
Normal file
81
apps/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { app, Menu } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
|
||||||
|
import { APP_ID } from './config';
|
||||||
|
import { registerIpcHandlers } from './ipc';
|
||||||
|
import { registerAppProtocol, registerSchemePrivileges } from './protocol';
|
||||||
|
import { registerGlobalShortcut, unregisterGlobalShortcut } from './shortcut';
|
||||||
|
import { createTray } from './tray';
|
||||||
|
import { initUpdater } from './updater';
|
||||||
|
import { createMainWindow, getMainWindow, markQuitting, showMainWindow } from './window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主进程入口:负责应用生命周期编排
|
||||||
|
* 启动顺序:单实例锁 → 特权协议注册(必须在 ready 前)→ ready 后依次初始化
|
||||||
|
* 协议服务 / IPC / 主窗口 / 托盘 / 全局快捷键 / 自动更新
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 显式设置应用名:userData / 日志目录会随之落到 %APPDATA%\萧康云医,
|
||||||
|
// 不设置的话 Electron 会取 package.json 的 name(@vben/desktop),生成嵌套目录不利于运维排查
|
||||||
|
app.setName('萧康云医');
|
||||||
|
|
||||||
|
// Windows 通知归属与任务栏分组标识,必须与 electron-builder 的 appId 一致,
|
||||||
|
// 否则系统通知不显示或显示为「electron.app.Electron」
|
||||||
|
app.setAppUserModelId(APP_ID);
|
||||||
|
|
||||||
|
// 单实例锁:避免多开导致托盘、自动更新、本地设置互相干扰
|
||||||
|
if (app.requestSingleInstanceLock()) {
|
||||||
|
// 特权协议必须在 app ready 之前注册,否则 app:// 无法成为安全上下文
|
||||||
|
registerSchemePrivileges();
|
||||||
|
|
||||||
|
// 重复启动(用户再次双击图标)时唤起已有窗口
|
||||||
|
app.on('second-instance', () => showMainWindow());
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// electron-log 初始化:主进程日志落 userData/logs,便于排查线上问题
|
||||||
|
log.initialize();
|
||||||
|
|
||||||
|
// mac 必须保留应用菜单,否则 Cmd+C/V/Q 等系统快捷键失效;Windows 隐藏菜单栏
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
Menu.setApplicationMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{ role: 'appMenu' },
|
||||||
|
{ role: 'editMenu' },
|
||||||
|
{ role: 'viewMenu' },
|
||||||
|
{ role: 'windowMenu' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Menu.setApplicationMenu(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerAppProtocol();
|
||||||
|
registerIpcHandlers();
|
||||||
|
|
||||||
|
// 开机自启带 --hidden 参数:静默启动进托盘,不弹主窗口打扰用户
|
||||||
|
const startHidden = process.argv.includes('--hidden');
|
||||||
|
createMainWindow({ startHidden });
|
||||||
|
|
||||||
|
createTray();
|
||||||
|
registerGlobalShortcut();
|
||||||
|
initUpdater();
|
||||||
|
});
|
||||||
|
|
||||||
|
// mac 点击 Dock 图标恢复窗口(关窗只是隐藏,符合平台惯例)
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (getMainWindow()) showMainWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 常驻托盘模式:窗口全部关闭也不退出(close 已被拦截为隐藏,这里是兜底),退出走托盘菜单/Cmd+Q
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// 故意留空:不调用 app.quit()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 任何退出路径(托盘退出 / Cmd+Q / 自动更新安装)都先放行窗口 close 拦截
|
||||||
|
app.on('before-quit', () => markQuitting());
|
||||||
|
|
||||||
|
// 退出前注销全局快捷键,避免残留占用
|
||||||
|
app.on('will-quit', () => unregisterGlobalShortcut());
|
||||||
|
} else {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
117
apps/desktop/src/main/ipc.ts
Normal file
117
apps/desktop/src/main/ipc.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { app, ipcMain, Notification } from 'electron';
|
||||||
|
|
||||||
|
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
|
||||||
|
import { applyBadge } from './badge';
|
||||||
|
import { printHtml, type PrintHtmlOptions } from './printer';
|
||||||
|
import { getSettings, updateSettings, type DesktopSettings } from './settings';
|
||||||
|
import { refreshTrayMenu } from './tray';
|
||||||
|
import { checkForUpdates } from './updater';
|
||||||
|
import { getMainWindow, isTrustedPageUrl, showMainWindow } from './window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC 汇总:preload 暴露的 desktopAPI 全部经由这里进入主进程
|
||||||
|
* 所有 handler 先校验调用方来源(app:// 或开发服务器),
|
||||||
|
* 防止页面内嵌第三方 iframe 等场景越权调用桌面能力
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 校验 IPC 调用来源,不可信来源直接抛错拒绝 */
|
||||||
|
function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void {
|
||||||
|
const url = event.senderFrame?.url ?? '';
|
||||||
|
if (!isTrustedPageUrl(url)) {
|
||||||
|
throw new Error(`拒绝来自不可信来源的 IPC 调用: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统通知参数(route 用于点击通知后让渲染端跳转对应页面) */
|
||||||
|
interface NotifyPayload {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
route?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerIpcHandlers(): void {
|
||||||
|
// 应用版本号(渲染端「关于/设置」展示用)
|
||||||
|
ipcMain.handle('desktop:get-version', (event) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
return app.getVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 系统通知:点击后唤起主窗口,并把目标路由回传渲染端执行 router.push
|
||||||
|
ipcMain.handle('desktop:notify', (event, payload: NotifyPayload) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
if (!Notification.isSupported()) return false;
|
||||||
|
const notification = new Notification({
|
||||||
|
title: payload.title,
|
||||||
|
body: payload.body,
|
||||||
|
});
|
||||||
|
notification.on('click', () => {
|
||||||
|
showMainWindow();
|
||||||
|
if (payload.route) {
|
||||||
|
getMainWindow()?.webContents.send('desktop:navigate', payload.route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notification.show();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 未读角标(mac Dock 数字 / Windows 任务栏覆盖图标,图由 preload canvas 绘制)
|
||||||
|
ipcMain.handle('desktop:set-badge', (event, payload: { count: number; image?: string }) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
applyBadge(payload.count, payload.image);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 任务栏闪烁提醒:仅窗口未聚焦时触发(聚焦状态下闪烁是干扰),聚焦后由窗口 focus 事件自动清除
|
||||||
|
ipcMain.handle('desktop:flash-frame', (event) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
const win = getMainWindow();
|
||||||
|
if (win && !win.isFocused()) win.flashFrame(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开机自启读取/设置(设置后同步刷新托盘菜单勾选态)
|
||||||
|
ipcMain.handle('desktop:get-auto-launch', (event) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
return getAutoLaunchEnabled();
|
||||||
|
});
|
||||||
|
ipcMain.handle('desktop:set-auto-launch', (event, enable: boolean) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
const result = setAutoLaunchEnabled(enable);
|
||||||
|
refreshTrayMenu();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 打印机列表(渲染端打印设置下拉用)
|
||||||
|
ipcMain.handle('desktop:get-printers', async (event) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
const win = getMainWindow();
|
||||||
|
if (!win) return [];
|
||||||
|
return await win.webContents.getPrintersAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 静默打印:渲染端拼好完整 HTML 传入,主进程隐藏窗口出纸
|
||||||
|
ipcMain.handle('desktop:print-html', (event, options: PrintHtmlOptions) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
return printHtml(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 桌面端设置(打印机选择、静默开关等)读取/增量更新
|
||||||
|
ipcMain.handle('desktop:get-settings', (event) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
return getSettings();
|
||||||
|
});
|
||||||
|
ipcMain.handle('desktop:set-settings', (event, patch: Partial<DesktopSettings>) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
return updateSettings(patch);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 手动检查更新(渲染端设置页/关于入口)
|
||||||
|
ipcMain.handle('desktop:check-update', (event) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
checkForUpdates(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染端主动唤起主窗口(如页内提醒的「查看」动作)
|
||||||
|
ipcMain.handle('desktop:focus-window', (event) => {
|
||||||
|
assertTrustedSender(event);
|
||||||
|
showMainWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
75
apps/desktop/src/main/printer.ts
Normal file
75
apps/desktop/src/main/printer.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静默打印:隐藏窗口加载 HTML → webContents.print 直接出纸
|
||||||
|
* 为什么用隐藏窗口:渲染层的处方打印本来就是拼好的完整 HTML(含内联样式),
|
||||||
|
* 加载到独立隐藏窗口打印可以完全复用现有模板,且不阻塞/不干扰主窗口。
|
||||||
|
* 为什么落临时文件而不是 data: URL:处方 HTML 可能较大,data URL 有长度上限风险,
|
||||||
|
* 临时文件 loadFile 对任意大小都稳定,打完即删。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PrintHtmlOptions {
|
||||||
|
/** 完整的打印 HTML(含样式),由渲染层拼好传入 */
|
||||||
|
html: string;
|
||||||
|
/** 目标打印机名称;不传时使用系统默认打印机 */
|
||||||
|
deviceName?: string;
|
||||||
|
/** 是否静默打印(true 不弹系统打印对话框) */
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrintResult {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行打印,返回是否成功与失败原因(渲染层据此提示用户)
|
||||||
|
*/
|
||||||
|
export async function printHtml(options: PrintHtmlOptions): Promise<PrintResult> {
|
||||||
|
const { html, deviceName, silent = true } = options;
|
||||||
|
// 临时文件带时间戳防并发打印互相覆盖
|
||||||
|
const tempFile = join(app.getPath('temp'), `xk-print-${Date.now()}-${Math.random().toString(36).slice(2)}.html`);
|
||||||
|
await writeFile(tempFile, html, 'utf8');
|
||||||
|
|
||||||
|
return new Promise<PrintResult>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
webPreferences: { sandbox: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 统一收尾:销毁隐藏窗口、清理临时文件、只 resolve 一次 */
|
||||||
|
const finish = (ok: boolean, message?: string) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (!win.isDestroyed()) win.destroy();
|
||||||
|
unlink(tempFile).catch(() => {});
|
||||||
|
if (!ok) log.warn(`静默打印失败: ${message}`);
|
||||||
|
resolve({ ok, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
win.webContents.print(
|
||||||
|
{
|
||||||
|
silent,
|
||||||
|
deviceName: deviceName || undefined,
|
||||||
|
printBackground: true,
|
||||||
|
},
|
||||||
|
(success, failureReason) => finish(success, success ? undefined : failureReason),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
win.webContents.once('did-fail-load', (_event, _code, desc) => finish(false, desc));
|
||||||
|
|
||||||
|
// 兜底超时:防止打印机脱机等场景下隐藏窗口泄漏
|
||||||
|
setTimeout(() => finish(false, '打印超时(请检查打印机状态)'), 60_000);
|
||||||
|
|
||||||
|
win.loadFile(tempFile).catch((error: unknown) => {
|
||||||
|
finish(false, error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
108
apps/desktop/src/main/protocol.ts
Normal file
108
apps/desktop/src/main/protocol.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { existsSync, statSync } from 'node:fs';
|
||||||
|
import { join, normalize } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
import { app, net, protocol } from 'electron';
|
||||||
|
|
||||||
|
import { API_UPSTREAM, APP_SCHEME, IM_UPSTREAM } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app:// 自定义协议:桌面端的「本地 nginx」
|
||||||
|
* 1. 静态文件服务:把 web-antd 生产 dist 按 URL 路径映射到本地文件,带 SPA fallback
|
||||||
|
* (等价 nginx 的 try_files $uri /index.html),因此无需把前端路由改成 hash 模式
|
||||||
|
* 2. 反向代理:/api/admin/* 与 /im-api/* 用 net.fetch 转发到线上上游 ——
|
||||||
|
* 渲染层看到的是同源请求,天然规避 CORS,后端与 nginx 都不需要任何改动
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Electron net.fetch 支持但标准 RequestInit 类型缺失的扩展字段 */
|
||||||
|
type ElectronFetchInit = RequestInit & {
|
||||||
|
/** 转发出去的请求不再进入自定义协议处理器,防止死循环 */
|
||||||
|
bypassCustomProtocolHandlers?: boolean;
|
||||||
|
/** 携带流式 body 时 fetch 规范要求显式声明半双工 */
|
||||||
|
duplex?: 'half';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册特权协议,必须在 app ready 之前调用
|
||||||
|
* standard/secure 让 app:// 成为「安全上下文」,navigator.clipboard、getUserMedia、
|
||||||
|
* IndexedDB、crypto.subtle 等浏览器能力才可用;stream 支持流式响应(音频等大文件)
|
||||||
|
*/
|
||||||
|
export function registerSchemePrivileges(): void {
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: APP_SCHEME,
|
||||||
|
privileges: {
|
||||||
|
standard: true,
|
||||||
|
secure: true,
|
||||||
|
supportFetchAPI: true,
|
||||||
|
stream: true,
|
||||||
|
corsEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 web dist 根目录
|
||||||
|
* 打包后位于 resources/web(electron-builder extraResources 拷入,不进 asar);
|
||||||
|
* 未打包(本地验证生产包行为)时兜底读仓库内 web-antd/dist
|
||||||
|
*/
|
||||||
|
export function getWebRoot(): string {
|
||||||
|
return app.isPackaged
|
||||||
|
? join(process.resourcesPath, 'web')
|
||||||
|
: join(__dirname, '../../../web-antd/dist');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把渲染层的同源请求转发到远端上游(等价 nginx proxy_pass)
|
||||||
|
* 保留原请求的方法 / 头 / 流式请求体,Bearer token 请求头原样透传
|
||||||
|
*/
|
||||||
|
function proxyRequest(target: string, request: Request): Promise<Response> {
|
||||||
|
const init: ElectronFetchInit = {
|
||||||
|
method: request.method,
|
||||||
|
headers: request.headers,
|
||||||
|
body: request.body,
|
||||||
|
bypassCustomProtocolHandlers: true,
|
||||||
|
};
|
||||||
|
if (request.body) init.duplex = 'half';
|
||||||
|
return net.fetch(target, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静态文件解析:带路径穿越防护 + SPA fallback
|
||||||
|
* 命中不了真实文件的路径(前端路由地址、不存在的资源)统一回 index.html 交给前端路由
|
||||||
|
*/
|
||||||
|
function resolveStaticFile(webRoot: string, pathname: string): string {
|
||||||
|
const indexHtml = join(webRoot, 'index.html');
|
||||||
|
try {
|
||||||
|
const filePath = normalize(join(webRoot, decodeURIComponent(pathname)));
|
||||||
|
// 防路径穿越:解析结果必须仍在 webRoot 目录内
|
||||||
|
if (!filePath.startsWith(normalize(webRoot))) return indexHtml;
|
||||||
|
if (existsSync(filePath) && statSync(filePath).isFile()) return filePath;
|
||||||
|
} catch {
|
||||||
|
// decode 失败等异常场景直接回 index.html,不让协议处理器抛错
|
||||||
|
}
|
||||||
|
return indexHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 app:// 协议处理器,在 app ready 之后调用
|
||||||
|
*/
|
||||||
|
export function registerAppProtocol(): void {
|
||||||
|
const webRoot = getWebRoot();
|
||||||
|
protocol.handle(APP_SCHEME, (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
// 反代 /api/admin/*:生产构建的 VITE_GLOB_API_URL=/api/admin,路径直接透传
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
return proxyRequest(`${API_UPSTREAM}${url.pathname}${url.search}`, request);
|
||||||
|
}
|
||||||
|
// 反代 /im-api/*:对齐开发代理规则,剥掉 /im-api 前缀后拼到上游 /api/*
|
||||||
|
if (url.pathname.startsWith('/im-api/')) {
|
||||||
|
const stripped = url.pathname.slice('/im-api'.length);
|
||||||
|
return proxyRequest(`${IM_UPSTREAM}/api${stripped}${url.search}`, request);
|
||||||
|
}
|
||||||
|
// 其余全部按静态资源处理(net.fetch file:// 会按扩展名自动带上正确的 Content-Type)
|
||||||
|
const fileUrl = pathToFileURL(resolveStaticFile(webRoot, url.pathname)).toString();
|
||||||
|
return net.fetch(fileUrl, { bypassCustomProtocolHandlers: true } as ElectronFetchInit);
|
||||||
|
});
|
||||||
|
}
|
||||||
67
apps/desktop/src/main/settings.ts
Normal file
67
apps/desktop/src/main/settings.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { app } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面端本地设置持久化(userData/desktop-settings.json)
|
||||||
|
* 为什么不用 electron-store:目前只需存打印机等少量键值,直接读写 JSON 文件即可,
|
||||||
|
* 避免引入额外依赖(electron-store 新版为纯 ESM,与 CJS 主进程产物不兼容,还会拖入 ajv 等重依赖)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 桌面端设置项定义 */
|
||||||
|
export interface DesktopSettings {
|
||||||
|
/** 静默打印使用的打印机名称,空串表示未选择 */
|
||||||
|
printerName: string;
|
||||||
|
/** 是否启用静默打印(true 时打印处方不弹系统打印对话框) */
|
||||||
|
silentPrint: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: DesktopSettings = {
|
||||||
|
printerName: '',
|
||||||
|
silentPrint: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 内存缓存,避免每次 IPC 调用都读磁盘 */
|
||||||
|
let cache: DesktopSettings | null = null;
|
||||||
|
|
||||||
|
/** 设置文件落在 userData 目录,随应用卸载数据目录一起清理 */
|
||||||
|
function getSettingsFilePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'desktop-settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取设置:文件不存在或解析失败时回退默认值,保证任何情况下不影响应用启动
|
||||||
|
*/
|
||||||
|
export function getSettings(): DesktopSettings {
|
||||||
|
if (cache) return cache;
|
||||||
|
try {
|
||||||
|
const filePath = getSettingsFilePath();
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
// JSON.parse 返回 any,显式收窄成 Partial 再与默认值合并,保证字段完整
|
||||||
|
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<DesktopSettings>;
|
||||||
|
const merged: DesktopSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
cache = merged;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('读取桌面端设置失败,回退默认值', error);
|
||||||
|
}
|
||||||
|
cache = { ...DEFAULT_SETTINGS };
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量更新设置并写盘:写盘失败只告警(内存态仍生效),下次成功写入时自动恢复
|
||||||
|
*/
|
||||||
|
export function updateSettings(patch: Partial<DesktopSettings>): DesktopSettings {
|
||||||
|
const next = { ...getSettings(), ...patch };
|
||||||
|
cache = next;
|
||||||
|
try {
|
||||||
|
writeFileSync(getSettingsFilePath(), JSON.stringify(next, null, 2), 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('写入桌面端设置失败', error);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
21
apps/desktop/src/main/shortcut.ts
Normal file
21
apps/desktop/src/main/shortcut.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { globalShortcut } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
|
||||||
|
import { MAIN_WINDOW_SHORTCUT } from './config';
|
||||||
|
import { toggleMainWindow } from './window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局快捷键:任何界面下按 CommandOrControl+Alt+X 快速唤起/隐藏主窗口
|
||||||
|
* 注册失败(快捷键被其他应用占用)只记录警告,不影响应用启动
|
||||||
|
*/
|
||||||
|
export function registerGlobalShortcut(): void {
|
||||||
|
const ok = globalShortcut.register(MAIN_WINDOW_SHORTCUT, () => toggleMainWindow());
|
||||||
|
if (!ok) {
|
||||||
|
log.warn(`全局快捷键 ${MAIN_WINDOW_SHORTCUT} 注册失败(可能被其他应用占用)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用退出前注销全部快捷键,避免残留占用 */
|
||||||
|
export function unregisterGlobalShortcut(): void {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
}
|
||||||
78
apps/desktop/src/main/tray.ts
Normal file
78
apps/desktop/src/main/tray.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { app, Menu, nativeImage, Tray } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
|
||||||
|
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
|
||||||
|
import { getResourcePath } from './config';
|
||||||
|
import { checkForUpdates } from './updater';
|
||||||
|
import { markQuitting, showMainWindow } from './window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统托盘:应用常驻入口
|
||||||
|
* - Windows 单击托盘图标恢复主窗口;mac 单击弹出菜单(跟随平台惯例)
|
||||||
|
* - 菜单:显示主窗口 / 开机自启开关 / 检查更新 / 退出
|
||||||
|
* 注意:托盘实例必须保持模块级引用,否则会被 GC 导致图标消失(Electron 经典坑)
|
||||||
|
*/
|
||||||
|
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
|
||||||
|
/** 构建托盘菜单:开机自启是动态勾选项,每次状态变化后需重建菜单 */
|
||||||
|
function buildTrayMenu(): Menu {
|
||||||
|
return Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '显示主窗口',
|
||||||
|
click: () => showMainWindow(),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '开机自启',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: getAutoLaunchEnabled(),
|
||||||
|
// 开发模式登录项无意义,置灰避免误操作
|
||||||
|
enabled: app.isPackaged,
|
||||||
|
click: (menuItem) => {
|
||||||
|
setAutoLaunchEnabled(menuItem.checked);
|
||||||
|
refreshTrayMenu();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '检查更新',
|
||||||
|
click: () => checkForUpdates(true),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
// 先标记退出,放行主窗口 close 拦截,再走正常退出流程
|
||||||
|
markQuitting();
|
||||||
|
app.quit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新托盘菜单(开机自启状态从渲染端 IPC 修改后也要同步勾选态) */
|
||||||
|
export function refreshTrayMenu(): void {
|
||||||
|
tray?.setContextMenu(buildTrayMenu());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建系统托盘
|
||||||
|
* 图标用打包的应用图标缩放(mac 菜单栏建议 18px、Windows 托盘 16px 的倍数由系统处理)
|
||||||
|
* TODO(待确认):正式图标到位后,mac 建议换单色 Template 图以适配深浅菜单栏
|
||||||
|
*/
|
||||||
|
export function createTray(): void {
|
||||||
|
try {
|
||||||
|
const icon = nativeImage.createFromPath(getResourcePath('icon.png'));
|
||||||
|
const trayIcon = process.platform === 'darwin' ? icon.resize({ width: 18, height: 18 }) : icon.resize({ width: 16, height: 16 });
|
||||||
|
tray = new Tray(trayIcon);
|
||||||
|
tray.setToolTip('萧康云医');
|
||||||
|
tray.setContextMenu(buildTrayMenu());
|
||||||
|
// Windows 单击直接唤起主窗口;mac 上设置了 contextMenu 后单击默认弹菜单,不重复绑定
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
tray.on('click', () => showMainWindow());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 托盘创建失败(图标缺失等)不阻塞应用启动,仅损失常驻入口
|
||||||
|
log.error('创建系统托盘失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
apps/desktop/src/main/updater.ts
Normal file
139
apps/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { app, dialog, shell } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
import { autoUpdater } from 'electron-updater';
|
||||||
|
|
||||||
|
import { DOWNLOAD_PAGE_URL } from './config';
|
||||||
|
import { getMainWindow, markQuitting, showMainWindow } from './window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动更新(electron-updater + generic 静态托管)
|
||||||
|
* - 启动 10 秒后自动检查一次 + 每 4 小时轮询 + 托盘/渲染端手动触发
|
||||||
|
* - autoDownload:发现新版本自动后台下载,进度事件转发渲染端展示
|
||||||
|
* - 下载完成弹窗询问「立即重启安装 / 退出时自动安装」
|
||||||
|
* - mac 未签名包 electron-updater 签名校验会报错:捕获后降级为「打开下载页手动安装」
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 手动检查标记:手动触发时才提示「已是最新版本」,自动轮询保持安静 */
|
||||||
|
let isManualCheck = false;
|
||||||
|
/** 检查中防重入 */
|
||||||
|
let isChecking = false;
|
||||||
|
|
||||||
|
/** 把更新事件转发给渲染端(web 侧在 basic.vue 里做轻量提示) */
|
||||||
|
function sendToRenderer(type: string, data?: unknown): void {
|
||||||
|
getMainWindow()?.webContents.send('desktop:update-event', { type, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断是否 mac 未签名导致的更新失败(这类错误重试无意义,直接引导手动下载) */
|
||||||
|
function isMacSignatureError(message: string): boolean {
|
||||||
|
return (
|
||||||
|
process.platform === 'darwin' &&
|
||||||
|
/code signature|not signed|codesign|could not get code signature/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化自动更新:仅打包后生效(开发模式没有 app-update.yml,检查必然失败)
|
||||||
|
*/
|
||||||
|
export function initUpdater(): void {
|
||||||
|
if (!app.isPackaged) return;
|
||||||
|
|
||||||
|
autoUpdater.logger = log;
|
||||||
|
autoUpdater.autoDownload = true;
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', (info) => {
|
||||||
|
log.info(`发现新版本: ${info.version}`);
|
||||||
|
sendToRenderer('available', { version: info.version });
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-not-available', () => {
|
||||||
|
sendToRenderer('not-available');
|
||||||
|
if (isManualCheck) {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: '检查更新',
|
||||||
|
message: `当前已是最新版本(v${app.getVersion()})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isManualCheck = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('download-progress', (progress) => {
|
||||||
|
sendToRenderer('progress', { percent: Math.round(progress.percent) });
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-downloaded', async (info) => {
|
||||||
|
sendToRenderer('downloaded', { version: info.version });
|
||||||
|
// 唤起窗口再弹对话框,避免用户看不到安装确认
|
||||||
|
showMainWindow();
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'question',
|
||||||
|
title: '发现新版本',
|
||||||
|
message: `新版本 v${info.version} 已下载完成`,
|
||||||
|
detail: '立即重启安装,或在退出应用时自动安装。',
|
||||||
|
buttons: ['立即重启安装', '退出时自动安装'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
});
|
||||||
|
if (response === 0) {
|
||||||
|
// 放行窗口 close 拦截,否则 quitAndInstall 会被「关闭进托盘」逻辑挡住
|
||||||
|
markQuitting();
|
||||||
|
autoUpdater.quitAndInstall();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('error', async (error) => {
|
||||||
|
const message = error?.message ?? String(error);
|
||||||
|
log.error('自动更新失败', message);
|
||||||
|
sendToRenderer('error', { message });
|
||||||
|
// mac 未签名:自动更新走不通,引导用户手动下载安装
|
||||||
|
if (isMacSignatureError(message)) {
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: '检查更新',
|
||||||
|
message: '当前安装包不支持自动更新',
|
||||||
|
detail: '请前往下载页获取最新版本手动安装。',
|
||||||
|
buttons: ['打开下载页', '取消'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
});
|
||||||
|
if (response === 0) shell.openExternal(DOWNLOAD_PAGE_URL);
|
||||||
|
} else if (isManualCheck) {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: '检查更新',
|
||||||
|
message: '检查更新失败,请稍后重试',
|
||||||
|
detail: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isManualCheck = false;
|
||||||
|
isChecking = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动延时自动检查一次 + 每 4 小时轮询(内部工具常开着,轮询保证及时收到发版)
|
||||||
|
setTimeout(() => checkForUpdates(false), 10_000);
|
||||||
|
setInterval(() => checkForUpdates(false), 4 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发一次更新检查
|
||||||
|
* @param manual 是否手动触发(手动时「已是最新/失败」都会弹窗反馈,自动轮询保持安静)
|
||||||
|
*/
|
||||||
|
export async function checkForUpdates(manual: boolean): Promise<void> {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
if (manual) {
|
||||||
|
dialog.showMessageBox({ type: 'info', title: '检查更新', message: '开发模式不支持检查更新' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isChecking) return;
|
||||||
|
isChecking = true;
|
||||||
|
isManualCheck = manual;
|
||||||
|
try {
|
||||||
|
await autoUpdater.checkForUpdates();
|
||||||
|
} catch {
|
||||||
|
// 错误已在 error 事件里统一处理
|
||||||
|
} finally {
|
||||||
|
isChecking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
apps/desktop/src/main/window.ts
Normal file
212
apps/desktop/src/main/window.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { app, BrowserWindow, Notification, session, shell, systemPreferences } from 'electron';
|
||||||
|
import log from 'electron-log/main';
|
||||||
|
|
||||||
|
import { APP_INDEX_URL, APP_ORIGIN, DEV_SERVER_URL, getResourcePath } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主窗口管理
|
||||||
|
* - 加载目标:开发模式加载 web-antd 的 vite dev server(未起服务时自动重试);打包模式加载 app://local/
|
||||||
|
* - 安全基线:contextIsolation + sandbox 开启,webSecurity 不关闭;window.open 一律交给系统浏览器
|
||||||
|
* - 关闭行为:双平台都是「关窗隐藏、应用常驻」(Windows 走托盘、mac 走 Dock),真正退出走托盘菜单/Cmd+Q
|
||||||
|
* - 权限控制:只放行业务需要的媒体(IM 音视频)/ 通知 / 剪贴板 / 全屏,其余默认拒绝
|
||||||
|
*/
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
/** 标记「真正退出」:常驻模式下 close 只是隐藏,只有 quit 流程才放行关闭 */
|
||||||
|
let isQuitting = false;
|
||||||
|
/** Windows 首次关窗提示「已最小化到托盘」,只提示一次避免打扰 */
|
||||||
|
let hasShownHideTip = false;
|
||||||
|
/** 加载失败重试定时器,避免叠加多个重试 */
|
||||||
|
let retryTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/** 主窗口要加载的页面地址:打包后走自定义协议,开发走 dev server */
|
||||||
|
const PAGE_URL = app.isPackaged ? APP_INDEX_URL : DEV_SERVER_URL;
|
||||||
|
|
||||||
|
export function markQuitting(): void {
|
||||||
|
isQuitting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
|
return mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 URL 是否属于信任的页面来源(本地协议或开发服务器),IPC 与权限校验共用 */
|
||||||
|
export function isTrustedPageUrl(url: string): boolean {
|
||||||
|
return url.startsWith(APP_ORIGIN) || url.startsWith(DEV_SERVER_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话级权限控制:白名单内且来源可信才放行
|
||||||
|
* media 供 IM 音视频通话;notifications 供 HTML5 Notification(业务主要走主进程通知,这里兜底)
|
||||||
|
*/
|
||||||
|
function setupPermissionHandlers(): void {
|
||||||
|
const allowedPermissions = new Set([
|
||||||
|
'media',
|
||||||
|
'notifications',
|
||||||
|
'clipboard-read',
|
||||||
|
'clipboard-sanitized-write',
|
||||||
|
'fullscreen',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||||
|
const trusted = isTrustedPageUrl(webContents.getURL());
|
||||||
|
if (!trusted || !allowedPermissions.has(permission)) {
|
||||||
|
callback(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// mac 首次使用摄像头/麦克风必须先过系统 TCC 授权弹窗,否则页面侧拿不到设备
|
||||||
|
if (permission === 'media' && process.platform === 'darwin') {
|
||||||
|
Promise.all([
|
||||||
|
systemPreferences.askForMediaAccess('microphone'),
|
||||||
|
systemPreferences.askForMediaAccess('camera'),
|
||||||
|
])
|
||||||
|
.then(() => callback(true))
|
||||||
|
// TCC 弹窗异常时仍放行,让页面侧走自己的错误提示
|
||||||
|
.catch(() => callback(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => {
|
||||||
|
return allowedPermissions.has(permission) && isTrustedPageUrl(requestingOrigin);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载失败时的内置错误页(数据 URL 形式,不依赖任何本地文件) */
|
||||||
|
function buildErrorPageUrl(reason: string): string {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>加载失败</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; display: flex; align-items: center; justify-content: center; height: 100vh;
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6f8; color: #333; }
|
||||||
|
.box { text-align: center; }
|
||||||
|
.title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.desc { font-size: 13px; color: #999; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<div class="title">页面加载失败,正在自动重试…</div>
|
||||||
|
<div class="desc">请检查网络连接(${reason})</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建主窗口
|
||||||
|
* @param options.startHidden 开机自启(--hidden)场景下静默启动,不弹窗打扰用户
|
||||||
|
*/
|
||||||
|
export function createMainWindow(options: { startHidden: boolean }): BrowserWindow {
|
||||||
|
setupPermissionHandlers();
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1100,
|
||||||
|
minHeight: 700,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
title: '萧康云医',
|
||||||
|
// Windows/Linux 窗口与任务栏图标;mac 使用打包进 app 的 icns,无需在此指定
|
||||||
|
icon: process.platform === 'darwin' ? undefined : getResourcePath('icon.png'),
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 首次渲染完成再显示,避免白屏闪烁;静默启动场景保持隐藏(用户从托盘/快捷键唤起)
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
if (!options.startHidden) mainWindow?.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// window.open / target=_blank:外链一律交给系统默认浏览器,不在应用内开新窗(安全 + 体验)
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
shell.openExternal(url);
|
||||||
|
}
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 页面内导航限制在信任来源内,跳外部地址时转交系统浏览器
|
||||||
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (isTrustedPageUrl(url)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
shell.openExternal(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载失败(dev server 未启动 / 断网等)时展示内置错误页并定时重试
|
||||||
|
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDesc, _url, isMainFrame) => {
|
||||||
|
if (!isMainFrame) return;
|
||||||
|
// ERR_ABORTED(-3)是导航被主动取消(如重复 loadURL),不算失败
|
||||||
|
if (errorCode === -3) return;
|
||||||
|
log.warn(`主窗口加载失败: ${errorCode} ${errorDesc}`);
|
||||||
|
mainWindow?.loadURL(buildErrorPageUrl(errorDesc || String(errorCode)));
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
// 开发模式快速重试等 vite 起服务;打包模式放慢节奏等网络恢复
|
||||||
|
retryTimer = setTimeout(() => mainWindow?.loadURL(PAGE_URL), app.isPackaged ? 5000 : 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 窗口聚焦即清除任务栏闪烁提醒(配合新消息 flashFrame)
|
||||||
|
mainWindow.on('focus', () => mainWindow?.flashFrame(false));
|
||||||
|
|
||||||
|
// F12 切换 DevTools:内部管理系统保留排障入口
|
||||||
|
mainWindow.webContents.on('before-input-event', (_event, input) => {
|
||||||
|
if (input.type === 'keyDown' && input.key === 'F12') {
|
||||||
|
mainWindow?.webContents.toggleDevTools();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭行为:双平台都拦截为隐藏(Windows 常驻托盘 / mac 常驻 Dock),退出流程放行
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (isQuitting) return;
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow?.hide();
|
||||||
|
// Windows 首次隐藏时提示托盘位置,避免用户以为应用被关闭
|
||||||
|
if (process.platform === 'win32' && !hasShownHideTip && Notification.isSupported()) {
|
||||||
|
hasShownHideTip = true;
|
||||||
|
new Notification({
|
||||||
|
title: '萧康云医仍在运行',
|
||||||
|
body: '应用已最小化到系统托盘,点击托盘图标可重新打开窗口',
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.loadURL(PAGE_URL);
|
||||||
|
return mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 唤起主窗口:还原最小化、显示并聚焦(通知点击 / 托盘 / 第二实例 / 快捷键共用) */
|
||||||
|
export function showMainWindow(): void {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换主窗口显隐(全局快捷键使用) */
|
||||||
|
export function toggleMainWindow(): void {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isVisible() && mainWindow.isFocused()) {
|
||||||
|
mainWindow.hide();
|
||||||
|
} else {
|
||||||
|
showMainWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/desktop/src/preload/index.ts
Normal file
102
apps/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载脚本:通过 contextBridge 向渲染层暴露受控的 desktopAPI
|
||||||
|
* - 渲染层(web-antd)通过 window.desktopAPI 使用桌面能力;浏览器环境没有该对象,
|
||||||
|
* 前端封装层(apps/web-antd/src/util/desktop.ts)会自动 no-op 降级
|
||||||
|
* - sandbox 模式下本文件必须是 CJS 产物(electron-vite 已按包配置输出 CJS),
|
||||||
|
* 且只能使用 Electron 提供的受限 API(contextBridge / ipcRenderer 等)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL
|
||||||
|
* 为什么在 preload 画:主进程没有 DOM/canvas;preload 运行在渲染进程内有 DOM,
|
||||||
|
* 由主进程 setOverlayIcon 消费,这是 Windows 数字角标的社区标准做法
|
||||||
|
*/
|
||||||
|
function drawBadgeImage(count: number): string | undefined {
|
||||||
|
try {
|
||||||
|
const size = 32;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return undefined;
|
||||||
|
// 红色圆底
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#f5222d';
|
||||||
|
ctx.fill();
|
||||||
|
// 白色数字,超过 99 显示 99+
|
||||||
|
const text = count > 99 ? '99+' : String(count);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = `bold ${text.length > 2 ? 13 : 18}px Arial`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(text, size / 2, size / 2 + 1);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
} catch {
|
||||||
|
// canvas 不可用时退化为无图(主进程会清除角标),不影响业务
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 暴露给渲染层的桌面能力集合,字段语义与 web 侧 desktop.ts 封装一一对应 */
|
||||||
|
const desktopAPI = {
|
||||||
|
/** 渲染层判断是否运行在桌面端的唯一标记 */
|
||||||
|
isElectron: true as const,
|
||||||
|
/** 当前平台:win32 / darwin / linux */
|
||||||
|
platform: process.platform,
|
||||||
|
/** 获取桌面端版本号(关于/设置展示用) */
|
||||||
|
getVersion: (): Promise<string> => ipcRenderer.invoke('desktop:get-version'),
|
||||||
|
/** 发送系统通知;route 为点击通知后要跳转的前端路由 */
|
||||||
|
notify: (options: { title: string; body: string; route?: string }): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('desktop:notify', options),
|
||||||
|
/** 设置未读角标数量(0 表示清除);Windows 角标图在此侧 canvas 生成 */
|
||||||
|
setBadge: (count: number): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('desktop:set-badge', {
|
||||||
|
count,
|
||||||
|
image: count > 0 && process.platform === 'win32' ? drawBadgeImage(count) : undefined,
|
||||||
|
}),
|
||||||
|
/** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */
|
||||||
|
flashFrame: (): Promise<void> => ipcRenderer.invoke('desktop:flash-frame'),
|
||||||
|
/** 读取开机自启状态 */
|
||||||
|
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke('desktop:get-auto-launch'),
|
||||||
|
/** 设置开机自启,返回设置后的实际状态 */
|
||||||
|
setAutoLaunch: (enable: boolean): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('desktop:set-auto-launch', enable),
|
||||||
|
/** 获取系统打印机列表(打印设置下拉用) */
|
||||||
|
getPrinters: (): Promise<Array<{ name: string; displayName: string; isDefault: boolean }>> =>
|
||||||
|
ipcRenderer.invoke('desktop:get-printers'),
|
||||||
|
/** 静默打印:传入拼好的完整 HTML,主进程隐藏窗口出纸 */
|
||||||
|
printHTML: (options: {
|
||||||
|
html: string;
|
||||||
|
deviceName?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
}): Promise<{ ok: boolean; message?: string }> =>
|
||||||
|
ipcRenderer.invoke('desktop:print-html', options),
|
||||||
|
/** 读取桌面端设置(打印机名称、静默打印开关等) */
|
||||||
|
getSettings: (): Promise<{ printerName: string; silentPrint: boolean }> =>
|
||||||
|
ipcRenderer.invoke('desktop:get-settings'),
|
||||||
|
/** 增量更新桌面端设置,返回更新后的完整设置 */
|
||||||
|
setSettings: (
|
||||||
|
patch: Partial<{ printerName: string; silentPrint: boolean }>,
|
||||||
|
): Promise<{ printerName: string; silentPrint: boolean }> =>
|
||||||
|
ipcRenderer.invoke('desktop:set-settings', patch),
|
||||||
|
/** 手动检查更新(结果由主进程弹窗反馈) */
|
||||||
|
checkUpdate: (): Promise<void> => ipcRenderer.invoke('desktop:check-update'),
|
||||||
|
/** 唤起并聚焦主窗口 */
|
||||||
|
focusWindow: (): Promise<void> => ipcRenderer.invoke('desktop:focus-window'),
|
||||||
|
/** 监听「通知点击跳转」事件:主进程通知被点击后回传目标路由,渲染层执行 router.push */
|
||||||
|
onNavigate: (callback: (route: string) => void): void => {
|
||||||
|
ipcRenderer.on('desktop:navigate', (_event, route: string) => callback(route));
|
||||||
|
},
|
||||||
|
/** 监听更新事件(available / progress / downloaded / not-available / error),渲染层做轻量提示 */
|
||||||
|
onUpdateEvent: (callback: (payload: { type: string; data?: unknown }) => void): void => {
|
||||||
|
ipcRenderer.on('desktop:update-event', (_event, payload) => callback(payload));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
|
||||||
|
|
||||||
|
/** 供 web 侧类型声明引用(apps/web-antd/src/types/desktop.d.ts 与此保持同步) */
|
||||||
|
export type DesktopAPI = typeof desktopAPI;
|
||||||
18
apps/desktop/tsconfig.json
Normal file
18
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"useUnknownInCatchVariables": true
|
||||||
|
},
|
||||||
|
"include": ["src", "electron.vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -1,19 +1,71 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
<meta name="renderer" content="webkit" />
|
<meta name="renderer" content="webkit" />
|
||||||
<meta name="description" content="A Modern Back-end Management System" />
|
<!-- SEO:萧康云医管理后台品牌与说明 -->
|
||||||
<meta name="keywords" content="Vben Admin Vue3 Vite" />
|
<meta
|
||||||
<meta name="author" content="Vben" />
|
name="description"
|
||||||
|
content="萧康云医管理后台:诊所药店医联体 SaaS 运营中台,覆盖挂号引流、在线诊疗、订单财务、仓储备药、私域运营与古法煎药中心等业务管理。"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="萧康云医,管理后台,医联体SaaS,诊所管理,药店管理,在线诊疗,挂号引流,电子处方,财务对账"
|
||||||
|
/>
|
||||||
|
<meta name="author" content="萧康云医" />
|
||||||
|
<meta name="application-name" content="萧康云医" />
|
||||||
|
<meta name="theme-color" content="#4f6ef7" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
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="canonical" href="https://admin.xiaokang88.com/" />
|
||||||
|
<!-- 站点图标:浏览器标签 + iOS 主屏,与侧栏 logo 同源 -->
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" type="image/png" href="/img/logo.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/img/logo.png" />
|
||||||
|
<!-- Open Graph:分享卡片展示品牌 Logo 与说明 -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:locale" content="zh_CN" />
|
||||||
|
<meta property="og:site_name" content="萧康云医" />
|
||||||
|
<meta property="og:title" content="萧康云医管理后台" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="萧康云医管理后台:诊所药店医联体 SaaS 运营中台,覆盖挂号、诊疗、订单、财务与煎药中心等业务管理。"
|
||||||
|
/>
|
||||||
|
<meta property="og:url" content="https://admin.xiaokang88.com/" />
|
||||||
|
<meta property="og:image" content="https://admin.xiaokang88.com/img/logo.png" />
|
||||||
|
<meta property="og:image:alt" content="萧康云医 Logo" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="萧康云医管理后台" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="萧康云医管理后台:诊所药店医联体 SaaS 运营中台。"
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://admin.xiaokang88.com/img/logo.png" />
|
||||||
|
<!-- 结构化数据:搜索引擎识别组织与后台站点 -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"name": "萧康云医管理后台",
|
||||||
|
"applicationCategory": "BusinessApplication",
|
||||||
|
"operatingSystem": "Web",
|
||||||
|
"url": "https://admin.xiaokang88.com/",
|
||||||
|
"description": "萧康云医管理后台:诊所药店医联体 SaaS 运营中台,覆盖挂号引流、在线诊疗、订单财务、仓储备药、私域运营与古法煎药中心等业务管理。",
|
||||||
|
"image": "https://admin.xiaokang88.com/img/logo.png",
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "萧康云医",
|
||||||
|
"url": "https://admin.xiaokang88.com/",
|
||||||
|
"logo": "https://admin.xiaokang88.com/img/logo.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// 生产环境下注入百度统计
|
// 生产环境下注入百度统计
|
||||||
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
|
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
|
||||||
|
|||||||
@@ -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,24 @@
|
|||||||
"@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",
|
||||||
|
"lunar-typescript": "^1.8.6",
|
||||||
"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';
|
|
||||||
BIN
apps/web-antd/public/audio/register-notify.mp3
Normal file
BIN
apps/web-antd/public/audio/register-notify.mp3
Normal file
Binary file not shown.
@@ -3,55 +3,631 @@
|
|||||||
* 可用于 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';
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
type AdapterUploadProps = UploadProps & {
|
||||||
component: T,
|
aspectRatio?: string;
|
||||||
type: 'input' | 'select',
|
crop?: boolean;
|
||||||
) => {
|
draggable?: boolean;
|
||||||
return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
|
handleChange?: (event: UploadChangeParam) => void;
|
||||||
const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
|
maxSize?: number;
|
||||||
return h(component, { ...props, ...attrs, placeholder }, slots);
|
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>(
|
||||||
|
component: Component,
|
||||||
|
type: 'input' | 'select',
|
||||||
|
componentProps: Recordable<any> = {},
|
||||||
|
) => {
|
||||||
|
return defineComponent({
|
||||||
|
name: (component as any).name,
|
||||||
|
inheritAttrs: false,
|
||||||
|
// 必须声明 value:inheritAttrs:false 时若只靠 attrs 转发,异步 Input/Select
|
||||||
|
// 收不到 v-model,表现是能打字但表单仍是 defaultValue(查询参数一直为空)
|
||||||
|
props: {
|
||||||
|
placeholder: { type: String, default: undefined },
|
||||||
|
value: { default: undefined },
|
||||||
|
},
|
||||||
|
emits: ['update:value'],
|
||||||
|
setup: (props, { attrs, emit, expose, slots }) => {
|
||||||
|
// 透传内部组件暴露的方法(如 focus),给表单 autofocus 等使用
|
||||||
|
const innerRef = ref();
|
||||||
|
expose(
|
||||||
|
new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, key) => innerRef.value?.[key],
|
||||||
|
has: (_target, key) => key in (innerRef.value || {}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
const placeholder =
|
||||||
|
props.placeholder ||
|
||||||
|
(attrs.placeholder as string | undefined) ||
|
||||||
|
$t(`ui.placeholder.${type}`);
|
||||||
|
return h(
|
||||||
|
component,
|
||||||
|
{
|
||||||
|
...componentProps,
|
||||||
|
...attrs,
|
||||||
|
placeholder,
|
||||||
|
value: props.value,
|
||||||
|
'onUpdate:value': (val: unknown) => emit('update:value', val),
|
||||||
|
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 +637,8 @@ export type ComponentType =
|
|||||||
| 'DatePicker'
|
| 'DatePicker'
|
||||||
| 'DefaultButton'
|
| 'DefaultButton'
|
||||||
| 'Divider'
|
| 'Divider'
|
||||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
/** OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型) */
|
||||||
|
| 'GalleryPickLink'
|
||||||
| 'IconPicker'
|
| 'IconPicker'
|
||||||
| 'Input'
|
| 'Input'
|
||||||
| 'InputNumber'
|
| 'InputNumber'
|
||||||
@@ -85,66 +662,89 @@ export type ComponentType =
|
|||||||
| 'UploadDraggerPaste'
|
| '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',
|
||||||
|
}),
|
||||||
|
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
component: Select,
|
component: Select,
|
||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
visibleEvent: 'onDropdownVisibleChange',
|
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
},
|
visibleEvent: 'onVisibleChange',
|
||||||
slots,
|
}),
|
||||||
);
|
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
},
|
|
||||||
ApiTreeSelect: (props, { attrs, slots }) => {
|
|
||||||
return h(
|
|
||||||
ApiComponent,
|
|
||||||
{
|
|
||||||
placeholder: $t('ui.placeholder.select'),
|
|
||||||
...props,
|
|
||||||
...attrs,
|
|
||||||
component: TreeSelect,
|
component: TreeSelect,
|
||||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
optionsPropName: 'treeData',
|
optionsPropName: 'treeData',
|
||||||
visibleEvent: 'onVisibleChange',
|
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,
|
||||||
@@ -157,10 +757,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,19 +1,42 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断表单值是否「空」(必填校验共用)
|
||||||
|
* 为什么不写 value.length===0:InputNumber 是 number,.length 为 undefined,会误判/漏判
|
||||||
|
* 数字 0 视为有值(比例、金额等合法)
|
||||||
|
*/
|
||||||
|
function isEmptyFormValue(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null) return true;
|
||||||
|
if (typeof value === 'string') return value.trim() === '';
|
||||||
|
if (Array.isArray(value)) return value.length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验文案用的字段名:优先 label,避免 i18n {0} 裸露成「请输入x」 */
|
||||||
|
function ruleLabel(ctx: { label?: string; name?: string }): string {
|
||||||
|
return (ctx.label || ctx.name || '该项').trim() || '该项';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSetupVbenForm() {
|
||||||
setupVbenForm<ComponentType>({
|
setupVbenForm<ComponentType>({
|
||||||
config: {
|
config: {
|
||||||
// ant design vue组件库默认都是 v-model:value
|
// ant design vue组件库默认都是 v-model:value
|
||||||
baseModelPropName: 'value',
|
baseModelPropName: 'value',
|
||||||
|
// antd Input 的 onChange 带的是 event,打开回退后表单能从 event.target.value 取值
|
||||||
|
// (与 v-model:value 双通道写入,避免包装层漏绑时查询条件一直是空)
|
||||||
|
changeEventFallback: true,
|
||||||
|
|
||||||
// 一些组件是 v-model:checked 或者 v-model:fileList
|
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||||
|
// Vben* 走 modelValue,避免被 baseModelPropName=value 误绑后把 Event 写进字段
|
||||||
modelPropNameMap: {
|
modelPropNameMap: {
|
||||||
Checkbox: 'checked',
|
Checkbox: 'checked',
|
||||||
Radio: 'checked',
|
Radio: 'checked',
|
||||||
@@ -21,29 +44,65 @@ setupVbenForm<ComponentType>({
|
|||||||
Upload: 'fileList',
|
Upload: 'fileList',
|
||||||
UploadImage: 'modelValue',
|
UploadImage: 'modelValue',
|
||||||
UploadOssFile: 'modelValue',
|
UploadOssFile: 'modelValue',
|
||||||
|
VbenInput: 'modelValue',
|
||||||
|
VbenInputPassword: 'modelValue',
|
||||||
|
VbenPinInput: 'modelValue',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defineRules: {
|
rules: {
|
||||||
// 输入项目必填国际化适配
|
// 输入项目必填国际化适配(兼容 InputNumber number)
|
||||||
required: (value, _params, ctx) => {
|
required: (value, _params, ctx) => {
|
||||||
if (value === undefined || value === null || value.length === 0) {
|
if (isEmptyFormValue(value)) {
|
||||||
return $t('ui.formRules.required', [ctx.label]);
|
return $t('ui.formRules.required', [ruleLabel(ctx)]);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
// 选择项目必填国际化适配
|
// 选择项目必填国际化适配
|
||||||
selectRequired: (value, _params, ctx) => {
|
selectRequired: (value, _params, ctx) => {
|
||||||
if (value === undefined || value === null) {
|
if (isEmptyFormValue(value)) {
|
||||||
return $t('ui.formRules.selectRequired', [ctx.label]);
|
return $t('ui.formRules.selectRequired', [ruleLabel(ctx)]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 大陆手机号:必填 + 11 位且号段 13-19(与后端 UtilsService::checkPhone 一致)
|
||||||
|
* 表单字段 rules: 'mobile' 即可,无需再叠 required
|
||||||
|
*/
|
||||||
|
mobile: (value, _params, ctx) => {
|
||||||
|
const phone = typeof value === 'string' ? value.trim() : value;
|
||||||
|
if (isEmptyFormValue(phone)) {
|
||||||
|
return $t('ui.formRules.required', [ruleLabel(ctx)]);
|
||||||
|
}
|
||||||
|
if (!/^1[3456789]\d{9}$/.test(String(phone))) {
|
||||||
|
return $t('ui.formRules.mobile', [ruleLabel(ctx)]);
|
||||||
}
|
}
|
||||||
return true;
|
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';
|
||||||
|
|||||||
27
apps/web-antd/src/api/core/file-folder.ts
Normal file
27
apps/web-antd/src/api/core/file-folder.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'file-folder/';
|
||||||
|
|
||||||
|
/** 素材库文件夹,path 来自 object_key 按 / 拆出的目录 */
|
||||||
|
export interface FileFolderItem {
|
||||||
|
id: number;
|
||||||
|
pid: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件夹列表(会先按 object_key 同步),xk-api GET /file-folder/list */
|
||||||
|
export async function getFileFolderList() {
|
||||||
|
return requestClient.get<FileFolderItem[]>(`${prefix}list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动从 object_key 同步文件夹,xk-api POST /file-folder/sync */
|
||||||
|
export async function syncFileFolder() {
|
||||||
|
return requestClient.post<{
|
||||||
|
bound: number;
|
||||||
|
created: number;
|
||||||
|
folder_count: number;
|
||||||
|
}>(`${prefix}sync`);
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ export interface FileGalleryItem {
|
|||||||
original_name?: string;
|
original_name?: string;
|
||||||
object_key?: string;
|
object_key?: string;
|
||||||
source?: number;
|
source?: number;
|
||||||
|
is_qrcode?: number;
|
||||||
|
ref_count?: number;
|
||||||
|
folder_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileTypeItem {
|
export interface FileTypeItem {
|
||||||
@@ -32,28 +35,47 @@ export interface FileGalleryDetail extends FileGalleryItem {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileGalleryList(params?: {
|
export interface FileGalleryStats {
|
||||||
|
total_count: number;
|
||||||
|
total_size: number;
|
||||||
|
total_size_text: string;
|
||||||
|
unref_count: number;
|
||||||
|
recyclable_size: number;
|
||||||
|
recyclable_size_text: string;
|
||||||
|
has_scanned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileGalleryListParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
type?: number;
|
type?: number;
|
||||||
group_id?: number;
|
group_id?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
}) {
|
exclude_qrcode?: number;
|
||||||
|
unref_only?: number;
|
||||||
|
folder_id?: number;
|
||||||
|
unfiled?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileGalleryList(params?: FileGalleryListParams) {
|
||||||
return requestClient.get<{
|
return requestClient.get<{
|
||||||
items: FileGalleryItem[];
|
items: FileGalleryItem[];
|
||||||
total: number;
|
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
|
total: number;
|
||||||
}>(`${prefix}list`, { params });
|
}>(`${prefix}list`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileGalleryDetail(id: number) {
|
export async function getFileGalleryDetail(id: number) {
|
||||||
return requestClient.get<FileGalleryDetail>(`${prefix}detail`, { params: { id } });
|
return requestClient.get<FileGalleryDetail>(`${prefix}detail`, {
|
||||||
|
params: { id },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFileGalleryItem(id: number) {
|
export async function deleteFileGalleryItem(id: number | number[]) {
|
||||||
return requestClient.post<any>(`${prefix}delete`, { id });
|
const data = Array.isArray(id) ? { ids: id } : { id };
|
||||||
|
return requestClient.post<any>(`${prefix}delete`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameFileGalleryItem(id: number, file_name: string) {
|
export async function renameFileGalleryItem(id: number, file_name: string) {
|
||||||
@@ -66,8 +88,8 @@ export async function moveFileGalleryType(id: number, type: number) {
|
|||||||
|
|
||||||
export async function batchArchiveFiles() {
|
export async function batchArchiveFiles() {
|
||||||
return requestClient.post<{
|
return requestClient.post<{
|
||||||
reclassified: number;
|
|
||||||
file_name_filled: number;
|
file_name_filled: number;
|
||||||
|
reclassified: number;
|
||||||
}>(`${prefix}batch-archive`);
|
}>(`${prefix}batch-archive`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +98,77 @@ export async function getFileTypes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncFileGalleryFromOss() {
|
export async function syncFileGalleryFromOss() {
|
||||||
return requestClient.post<{ synced: number; skipped: number; scanned: number }>(`${prefix}sync-oss`);
|
return requestClient.post<{
|
||||||
|
scanned: number;
|
||||||
|
skipped: number;
|
||||||
|
synced: number;
|
||||||
|
}>(`${prefix}sync-oss`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文件加入自定义分组 */
|
/** 文件加入自定义分组 */
|
||||||
export async function addFileToGroup(file_id: number, group_id: number) {
|
export async function addFileToGroup(file_id: number, group_id: number) {
|
||||||
return requestClient.post<any>(`${prefix}add-to-group`, { file_id, group_id });
|
return requestClient.post<any>(`${prefix}add-to-group`, {
|
||||||
|
file_id,
|
||||||
|
group_id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文件移出自定义分组 */
|
/** 文件移出自定义分组 */
|
||||||
export async function removeFileFromGroup(file_id: number, group_id: number) {
|
export async function removeFileFromGroup(file_id: number, group_id: number) {
|
||||||
return requestClient.post<any>(`${prefix}remove-from-group`, { file_id, group_id });
|
return requestClient.post<any>(`${prefix}remove-from-group`, {
|
||||||
|
file_id,
|
||||||
|
group_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 素材库统计:总数 / 容量 / 未引用 / 可回收 */
|
||||||
|
export async function getFileGalleryStats() {
|
||||||
|
return requestClient.get<FileGalleryStats>(`${prefix}stats`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右键标记或取消二维码 */
|
||||||
|
export async function markFileQrcode(id: number, is_qrcode: number) {
|
||||||
|
return requestClient.post<{ id: number; is_qrcode: number }>(
|
||||||
|
`${prefix}mark-qrcode`,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
is_qrcode,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描业务表 URL 引用(两库全表文本列) */
|
||||||
|
export async function scanFileGalleryRefs() {
|
||||||
|
return requestClient.post<{
|
||||||
|
elapsed_ms: number;
|
||||||
|
mysql_columns: number;
|
||||||
|
mysql_failed: number;
|
||||||
|
old_mysql_columns: number;
|
||||||
|
old_mysql_failed: number;
|
||||||
|
referenced: number;
|
||||||
|
scanned_files: number;
|
||||||
|
unreferenced: number;
|
||||||
|
}>(`${prefix}scan-refs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量加入分组(移动到文件夹) */
|
||||||
|
export async function batchAddFilesToGroup(ids: number[], group_id: number) {
|
||||||
|
return requestClient.post<{ count: number; group_id: number }>(
|
||||||
|
`${prefix}batch-add-to-group`,
|
||||||
|
{
|
||||||
|
ids,
|
||||||
|
group_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量移入真实文件夹 */
|
||||||
|
export async function batchMoveFilesToFolder(ids: number[], folder_id: number) {
|
||||||
|
return requestClient.post<{ count: number; folder_id: number }>(
|
||||||
|
`${prefix}batch-move-to-folder`,
|
||||||
|
{
|
||||||
|
ids,
|
||||||
|
folder_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 该文件可自行根据业务逻辑进行调整
|
* 该文件可自行根据业务逻辑进行调整
|
||||||
*/
|
*/
|
||||||
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';
|
||||||
@@ -24,13 +25,16 @@ import {
|
|||||||
} from '#/constants/sensitive-fields';
|
} from '#/constants/sensitive-fields';
|
||||||
import { useAuthStore } from '#/store';
|
import { useAuthStore } from '#/store';
|
||||||
|
|
||||||
|
import { showPublicAccountLock } from '#/utils/publicAccountLock';
|
||||||
|
|
||||||
import { refreshTokenApi } from './core';
|
import { refreshTokenApi } from './core';
|
||||||
import {downloadByData} from "#/util/tool";
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,6 +137,12 @@ function createRequestClient(baseURL: string) {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 4101: {
|
||||||
|
showPublicAccountLock(result || {});
|
||||||
|
const lockError = new Error(responseData?.message || '公账日账单已逾期');
|
||||||
|
Object.assign(lockError, { silent: true, code: 4101, response });
|
||||||
|
throw lockError;
|
||||||
|
}
|
||||||
case 403:
|
case 403:
|
||||||
case 404:
|
case 404:
|
||||||
case 500: {
|
case 500: {
|
||||||
@@ -162,11 +172,11 @@ function createRequestClient(baseURL: string) {
|
|||||||
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
|
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
|
||||||
client.addResponseInterceptor(
|
client.addResponseInterceptor(
|
||||||
errorMessageResponseInterceptor((msg: string, error) => {
|
errorMessageResponseInterceptor((msg: string, error) => {
|
||||||
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
|
if (error?.silent || Number(error?.code) === 4101 || Number(error?.response?.data?.code) === 4101) {
|
||||||
// 当前mock接口返回的错误字段是 error 或者 message
|
return;
|
||||||
|
}
|
||||||
const responseData = error?.response?.data ?? {};
|
const responseData = error?.response?.data ?? {};
|
||||||
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
||||||
// 如果没有错误信息,则会根据状态码进行提示
|
|
||||||
message.error(errorMessage || msg);
|
message.error(errorMessage || msg);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -174,7 +184,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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { FileGalleryItem } from '#/api/core/file-gallery';
|
||||||
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
|
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getFileGalleryList } from '#/api/core/file-gallery';
|
||||||
import MIcon from '#/components/icon/icon.vue';
|
import MIcon from '#/components/icon/icon.vue';
|
||||||
import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
|
import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
|
||||||
import { resolveTypeIcon } from '#/composables/use-file-type-icon';
|
import { resolveTypeIcon } from '#/composables/use-file-type-icon';
|
||||||
import type { FileGalleryItem } from '#/api/core/file-gallery';
|
|
||||||
import { getFileGalleryList } from '#/api/core/file-gallery';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
open: boolean;
|
|
||||||
multiple?: boolean;
|
|
||||||
maxCount?: number;
|
|
||||||
/**
|
/**
|
||||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||||
@@ -21,6 +19,9 @@ const props = withDefaults(
|
|||||||
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
|
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
|
||||||
*/
|
*/
|
||||||
acceptTypes?: number[];
|
acceptTypes?: number[];
|
||||||
|
maxCount?: number;
|
||||||
|
multiple?: boolean;
|
||||||
|
open: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -30,8 +31,8 @@ const props = withDefaults(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:open': [value: boolean];
|
|
||||||
select: [urls: string[]];
|
select: [urls: string[]];
|
||||||
|
'update:open': [value: boolean];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -80,8 +81,9 @@ function lockAcceptType() {
|
|||||||
if (!arr || arr.length === 0) {
|
if (!arr || arr.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeType.value !== arr[0]) {
|
const firstType = arr[0];
|
||||||
setActiveType(arr[0]!);
|
if (firstType !== undefined && activeType.value !== firstType) {
|
||||||
|
setActiveType(firstType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +106,11 @@ async function load() {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getFileGalleryList(
|
const res = await getFileGalleryList({
|
||||||
buildListParams(page.value, pageSize.value),
|
...buildListParams(page.value, pageSize.value),
|
||||||
);
|
// 选择器隐藏二维码:标记字段 + 路径启发式由后端 exclude_qrcode 处理
|
||||||
|
exclude_qrcode: 1,
|
||||||
|
});
|
||||||
const data = (res as any)?.data ?? res;
|
const data = (res as any)?.data ?? res;
|
||||||
items.value = data?.items ?? [];
|
items.value = data?.items ?? [];
|
||||||
total.value = data?.total ?? 0;
|
total.value = data?.total ?? 0;
|
||||||
@@ -128,7 +132,7 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
let searchTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
@@ -153,7 +157,7 @@ watch(typesLoading, (val) => {
|
|||||||
function toggleSelect(url: string) {
|
function toggleSelect(url: string) {
|
||||||
if (props.multiple) {
|
if (props.multiple) {
|
||||||
const idx = selected.value.indexOf(url);
|
const idx = selected.value.indexOf(url);
|
||||||
if (idx >= 0) {
|
if (idx !== -1) {
|
||||||
selected.value = selected.value.filter((u) => u !== url);
|
selected.value = selected.value.filter((u) => u !== url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,7 +175,7 @@ function isSelected(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOk() {
|
function handleOk() {
|
||||||
if (!selected.value.length) {
|
if (selected.value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('select', [...selected.value]);
|
emit('select', [...selected.value]);
|
||||||
@@ -196,7 +200,7 @@ function onShowSizeChange(_current: number, size: number) {
|
|||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabChange(key: string | number) {
|
function onTabChange(key: number | string) {
|
||||||
setActiveType(Number(key));
|
setActiveType(Number(key));
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
void load();
|
void load();
|
||||||
@@ -308,8 +312,8 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
|
|
||||||
.tab-label {
|
.tab-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-toolbar {
|
.picker-toolbar {
|
||||||
@@ -321,18 +325,19 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-card {
|
.gallery-card {
|
||||||
@include picker.picker-card-base;
|
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|
||||||
|
@include picker.picker-card-base;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: #165dff;
|
|
||||||
background: #f2f7ff;
|
background: #f2f7ff;
|
||||||
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
|
border-color: #165dff;
|
||||||
|
box-shadow: 0 0 0 2px rgb(22 93 255 / 15%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,29 +345,29 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 88px;
|
height: 88px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-file-icon {
|
.gallery-file-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
|
||||||
height: 88px;
|
height: 88px;
|
||||||
border-radius: 6px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #4e5969;
|
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
color: #4e5969;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
|
||||||
word-break: break-all;
|
|
||||||
line-height: 1.2;
|
|
||||||
max-height: 28px;
|
max-height: 28px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,20 +377,20 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
|
|
||||||
.file-title {
|
.file-title {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
color: #1d2129;
|
align-items: center;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
color: #1d2129;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-pagination {
|
.gallery-pagination {
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -393,9 +398,9 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
@include picker.picker-card-dark-props;
|
@include picker.picker-card-dark-props;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: #60a5fa;
|
|
||||||
background: #374151;
|
background: #374151;
|
||||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 2px rgb(96 165 250 / 20%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Markdown 编辑器表单组件(封装 md-editor-v3)
|
||||||
|
* 经 component-map 自动注册,schema 中以 component: 'MarkdownEditor' 使用(vben form 默认 v-model:value 绑定)
|
||||||
|
* 为什么封装:统一精简工具栏(轻量备注场景不需要图片/公式等重型功能)+ 主题跟随系统亮暗切换,避免各表单重复配置
|
||||||
|
*/
|
||||||
|
import type { ToolbarNames } from 'md-editor-v3';
|
||||||
|
|
||||||
|
import { computed, defineAsyncComponent } from 'vue';
|
||||||
|
|
||||||
|
import { usePreferences } from '@vben/preferences';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
|
||||||
|
import 'md-editor-v3/lib/style.css';
|
||||||
|
|
||||||
|
// 本文件经 component-map 的 eager glob 全量打进主包,md-editor-v3(含 CodeMirror)体积大,
|
||||||
|
// 必须异步加载:外壳进主包、库本体在编辑器真正渲染时才拉取,避免拖累全站首屏
|
||||||
|
const MdEditor = defineAsyncComponent(() =>
|
||||||
|
import('md-editor-v3').then((m) => m.MdEditor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '请输入内容(支持 Markdown 格式)',
|
||||||
|
},
|
||||||
|
/** 编辑器整体高度(px),弹窗场景建议 200-300 */
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 240,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emits = defineEmits(['update:value']);
|
||||||
|
const mValue = useVModel(props, 'value', emits, {
|
||||||
|
defaultValue: props.value,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
/** 主题必须跟随系统亮/暗切换,不能写死(与消息详情 MdPreview 的处理一致) */
|
||||||
|
const { isDark } = usePreferences();
|
||||||
|
const theme = computed(() => (isDark.value ? 'dark' : 'light'));
|
||||||
|
/** 同页面可能挂载多个编辑器实例(如弹窗嵌套),随机 id 避免内部 DOM id 冲突 */
|
||||||
|
const editorId = `md-form-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
/** 精简工具栏:加粗/斜体/删除线、标题/引用/列表/任务、代码/链接/表格,右侧仅保留预览切换 */
|
||||||
|
const toolbars: ToolbarNames[] = [
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'strikeThrough',
|
||||||
|
'-',
|
||||||
|
'title',
|
||||||
|
'quote',
|
||||||
|
'unorderedList',
|
||||||
|
'orderedList',
|
||||||
|
'task',
|
||||||
|
'-',
|
||||||
|
'code',
|
||||||
|
'link',
|
||||||
|
'table',
|
||||||
|
'=',
|
||||||
|
'preview',
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<MdEditor
|
||||||
|
:id="editorId"
|
||||||
|
v-model="mValue"
|
||||||
|
:theme="theme"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:toolbars="toolbars"
|
||||||
|
:footers="[]"
|
||||||
|
:preview="false"
|
||||||
|
:style="{ height: `${height}px` }"
|
||||||
|
class="md-form-editor"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
/* 圆角与边框贴近 antd 输入组件,融入表单视觉 */
|
||||||
|
.md-form-editor {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import { useVModel } from '@vueuse/core';
|
|||||||
import { Avatar, Empty, Input, Popover, Spin, Tag } from 'ant-design-vue';
|
import { Avatar, Empty, Input, Popover, Spin, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
|
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
|
||||||
|
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||||
import { getPromoterOptions } from '#/views/system/store-input/api';
|
import { getPromoterOptions } from '#/views/system/store-input/api';
|
||||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 带快捷预设的搜索时间范围选择器
|
||||||
|
* - 上方 RangePicker 手选,下方快捷 link:今天 / 本周 / 本月 / 上一个月 / 下一个月
|
||||||
|
* - 支持 valueFormat='YYYY-MM-DD'(VbenForm)或 Dayjs 二元组(对账页)
|
||||||
|
*/
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { Button, DatePicker } from 'ant-design-vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatRangeByValueFormat,
|
||||||
|
normalizeRangeToYmd,
|
||||||
|
resolveSearchTimePreset,
|
||||||
|
type SearchTimePreset,
|
||||||
|
} from '#/utils/search-time-range';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SearchTimeRangePicker',
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
value?: [string, string] | [Dayjs, Dayjs] | null;
|
||||||
|
valueFormat?: 'YYYY-MM-DD';
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 是否展示翻月按钮 */
|
||||||
|
showMonthNav?: boolean;
|
||||||
|
/** 是否展示「本月」快捷(队列近7天页可关) */
|
||||||
|
showThisMonth?: boolean;
|
||||||
|
format?: string;
|
||||||
|
class?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
disabled: false,
|
||||||
|
showMonthNav: true,
|
||||||
|
showThisMonth: true,
|
||||||
|
format: 'YYYY-MM-DD',
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:value': [[string, string] | [Dayjs, Dayjs] | null];
|
||||||
|
change: [[string, string] | [Dayjs, Dayjs] | null];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const mValue = useVModel(props, 'value', emit, { passive: true });
|
||||||
|
|
||||||
|
/** RangePicker 内部统一用 Dayjs */
|
||||||
|
const innerRange = computed<[Dayjs, Dayjs] | null>({
|
||||||
|
get() {
|
||||||
|
if (!mValue.value || !Array.isArray(mValue.value) || mValue.value.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = normalizeRangeToYmd(mValue.value);
|
||||||
|
if (!normalized) return null;
|
||||||
|
return [dayjs(normalized[0]), dayjs(normalized[1])];
|
||||||
|
},
|
||||||
|
set(next) {
|
||||||
|
if (!next || !next[0] || !next[1]) {
|
||||||
|
mValue.value = null;
|
||||||
|
emit('change', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formatted = formatRangeByValueFormat(
|
||||||
|
[next[0].startOf('day'), next[1].startOf('day')],
|
||||||
|
props.valueFormat,
|
||||||
|
);
|
||||||
|
mValue.value = formatted as [string, string] | [Dayjs, Dayjs];
|
||||||
|
emit('change', formatted as [string, string] | [Dayjs, Dayjs]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type QuickItem = {
|
||||||
|
key: SearchTimePreset | 'thisMonth';
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickItems = computed<QuickItem[]>(() => {
|
||||||
|
const items: QuickItem[] = [
|
||||||
|
{ key: 'today', label: '今天' },
|
||||||
|
{ key: 'thisWeek', label: '本周' },
|
||||||
|
];
|
||||||
|
if (props.showThisMonth) {
|
||||||
|
items.push({ key: 'thisMonth', label: '本月' });
|
||||||
|
}
|
||||||
|
if (props.showMonthNav) {
|
||||||
|
items.push(
|
||||||
|
{ key: 'prevMonth', label: '上一个月' },
|
||||||
|
{ key: 'nextMonth', label: '下一个月' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 点击快捷预设 */
|
||||||
|
function applyPreset(preset: SearchTimePreset) {
|
||||||
|
const range = resolveSearchTimePreset(preset, mValue.value);
|
||||||
|
innerRange.value = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手选 RangePicker */
|
||||||
|
function onPickerChange(
|
||||||
|
dates: [Dayjs, Dayjs] | [string, string] | null,
|
||||||
|
) {
|
||||||
|
if (!dates || !Array.isArray(dates) || dates.length < 2) {
|
||||||
|
innerRange.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = dayjs(dates[0]);
|
||||||
|
const b = dayjs(dates[1]);
|
||||||
|
if (!a.isValid() || !b.isValid()) {
|
||||||
|
innerRange.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
innerRange.value = [a, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无值时展示默认本月至今(仅 UI 占位,不写回 v-model 直到用户操作) */
|
||||||
|
const pickerValue = computed<[Dayjs, Dayjs] | undefined>({
|
||||||
|
get() {
|
||||||
|
return innerRange.value ?? undefined;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (!val) {
|
||||||
|
innerRange.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
innerRange.value = val;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-time-range-picker" :class="props.class">
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
v-model:value="pickerValue"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:disabled="disabled"
|
||||||
|
:format="format"
|
||||||
|
:value-format="valueFormat"
|
||||||
|
class="search-time-range-picker__picker w-full"
|
||||||
|
@change="onPickerChange"
|
||||||
|
/>
|
||||||
|
<div class="search-time-range-picker__quick">
|
||||||
|
<Button
|
||||||
|
v-for="item in quickItems"
|
||||||
|
:key="item.key"
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="search-time-range-picker__quick-btn"
|
||||||
|
@click="applyPreset(item.key as SearchTimePreset)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.search-time-range-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-time-range-picker__quick {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-time-range-picker__quick-btn {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-time-range-picker__picker {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 门店多选搜索组件
|
* 门店多选搜索(对齐开方关键字气泡交互)
|
||||||
* - 普通输入框输入,下方气泡卡片展示匹配结果(名称 / 拼音首拼)
|
* - Teleport + fixed 锚定,避免搜索栏 overflow 裁切导致「气泡不见」
|
||||||
* - 选中后在输入框下方用可关闭 Tag 展示「名称【id】」
|
* - 多选:点选后不关闭面板,已选项从列表剔除并以 Tag 展示
|
||||||
* - 搜索结果默认高亮第一项,Enter 可直接选中
|
* - Enter / ↑↓ 键盘选用,Esc 关闭
|
||||||
* - 样式使用主题 CSS 变量,自动适配暗色
|
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||||
@@ -28,16 +36,11 @@ export type StoreSearchItem = {
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 已选门店 ID 列表 */
|
|
||||||
value?: number[];
|
value?: number[];
|
||||||
/** 限定类型:0诊所 1药店;不传则诊所+药店都可搜 */
|
/** 0诊所 1药店;不传则诊所+药店 */
|
||||||
storeType?: 0 | 1;
|
storeType?: 0 | 1;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
|
||||||
* 外部已选门店详情(打开绑定弹窗时回填名称用)
|
|
||||||
* 仅用于 Tag 展示,不改变 value
|
|
||||||
*/
|
|
||||||
initialItems?: StoreSearchItem[];
|
initialItems?: StoreSearchItem[];
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -49,23 +52,65 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模块级搜索结果缓存(所有实例共享):key = `${userId}|${storeType}|${keyword}`
|
||||||
|
* 门店数据变更低频,TTL 内相同关键词直接命中缓存零等待,避免每敲一个字都等接口转圈。
|
||||||
|
* key 必须拼登录用户 id:不同账号数据权限不同(店管只能搜到本店可见门店),
|
||||||
|
* 切换账号后不允许在 TTL 内命中上一账号的结果,拼 id 后 key 天然隔离
|
||||||
|
*/
|
||||||
|
const searchResultCache = new Map<
|
||||||
|
string,
|
||||||
|
{ at: number; items: StoreSearchItem[] }
|
||||||
|
>();
|
||||||
|
const SEARCH_CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
const SEARCH_CACHE_MAX = 200;
|
||||||
|
|
||||||
|
function readSearchCache(key: string): null | StoreSearchItem[] {
|
||||||
|
const hit = searchResultCache.get(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (Date.now() - hit.at > SEARCH_CACHE_TTL) {
|
||||||
|
searchResultCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hit.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSearchCache(key: string, items: StoreSearchItem[]) {
|
||||||
|
// 简单容量控制:超限整体清空(低频触发,无需 LRU)
|
||||||
|
if (searchResultCache.size >= SEARCH_CACHE_MAX) {
|
||||||
|
searchResultCache.clear();
|
||||||
|
}
|
||||||
|
searchResultCache.set(key, { at: Date.now(), items });
|
||||||
|
}
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
'update:value': [value: number[]];
|
'update:value': [value: number[]];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
/** 当前登录用户 id(缓存隔离维度;后端返回的 admin 行主键是 id,兜底 vben 的 userId) */
|
||||||
|
function currentUserId(): number | string {
|
||||||
|
const info = userStore.userInfo as null | Record<string, any>;
|
||||||
|
return info?.id ?? info?.userId ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
const mValue = useVModel(props, 'value', emits, {
|
const mValue = useVModel(props, 'value', emits, {
|
||||||
|
// 本地立即更新才能连续多选;同时 commitIds 会同步 emit,避免回车搜索读到旧值
|
||||||
passive: true,
|
passive: true,
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const panelRef = ref<HTMLElement | null>(null);
|
||||||
|
const listRef = ref<HTMLElement | null>(null);
|
||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showDropdown = ref(false);
|
const showDropdown = ref(false);
|
||||||
const highlightIndex = ref(-1);
|
const highlightIndex = ref(-1);
|
||||||
const options = ref<StoreSearchItem[]>([]);
|
const options = ref<StoreSearchItem[]>([]);
|
||||||
/** 已选门店详情(用于 Tag 展示名称) */
|
|
||||||
const selectedMap = ref<Record<number, StoreSearchItem>>({});
|
const selectedMap = ref<Record<number, StoreSearchItem>>({});
|
||||||
|
const panelStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
const selectedIds = computed(() =>
|
const selectedIds = computed(() =>
|
||||||
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
|
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
|
||||||
@@ -78,38 +123,100 @@ const selectedItems = computed(() =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 下拉中过滤掉已选 */
|
/** 下拉中过滤掉已选,方便连续多选 */
|
||||||
const visibleOptions = computed(() =>
|
const visibleOptions = computed(() =>
|
||||||
options.value.filter((item) => !selectedIds.value.includes(item.id)),
|
options.value.filter((item) => !selectedIds.value.includes(item.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 将初始/回填门店写入 selectedMap,便于 Tag 显示真实名称
|
|
||||||
*/
|
|
||||||
function mergeInitialItems(items: StoreSearchItem[]) {
|
function mergeInitialItems(items: StoreSearchItem[]) {
|
||||||
if (!items?.length) return;
|
if (!items?.length) return;
|
||||||
const next = { ...selectedMap.value };
|
const next = { ...selectedMap.value };
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item?.id) {
|
if (item?.id) next[item.id] = item;
|
||||||
next[item.id] = item;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
selectedMap.value = next;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求序号:只采纳最后一次请求的响应,避免快速输入时慢响应覆盖新结果 */
|
||||||
|
let fetchSeq = 0;
|
||||||
|
|
||||||
async function fetchOptions() {
|
async function fetchOptions() {
|
||||||
const keyword = searchKeyword.value.trim();
|
const keyword = searchKeyword.value.trim();
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
|
// 递增序号让在途请求作废,避免清空后慢响应又把旧结果写回来
|
||||||
|
fetchSeq += 1;
|
||||||
|
loading.value = false;
|
||||||
options.value = [];
|
options.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
highlightIndex.value = -1;
|
highlightIndex.value = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${currentUserId()}|${props.storeType ?? 'all'}|${keyword}`;
|
||||||
|
const cachedItems = readSearchCache(cacheKey);
|
||||||
|
if (cachedItems) {
|
||||||
|
// 命中缓存:直接渲染,不发请求不转 loading(体感「秒出」)
|
||||||
|
fetchSeq += 1;
|
||||||
|
loading.value = false;
|
||||||
|
options.value = cachedItems;
|
||||||
|
await openPanel();
|
||||||
|
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
|
||||||
|
await nextTick();
|
||||||
|
updatePanelPlacement();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = (fetchSeq += 1);
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
showDropdown.value = true;
|
await openPanel();
|
||||||
try {
|
try {
|
||||||
const res = await searchStoreOption({
|
const res = await searchStoreOption({
|
||||||
keyword,
|
keyword,
|
||||||
@@ -118,16 +225,23 @@ async function fetchOptions() {
|
|||||||
: {}),
|
: {}),
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
options.value = res?.items ?? [];
|
if (seq !== fetchSeq) return;
|
||||||
// 默认高亮第一项,Enter 可直接选中
|
const items = res?.items ?? [];
|
||||||
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
writeSearchCache(cacheKey, items);
|
||||||
|
options.value = items;
|
||||||
|
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
|
||||||
|
await nextTick();
|
||||||
|
updatePanelPlacement();
|
||||||
} catch {
|
} catch {
|
||||||
|
if (seq !== fetchSeq) return;
|
||||||
options.value = [];
|
options.value = [];
|
||||||
highlightIndex.value = -1;
|
highlightIndex.value = -1;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (seq === fetchSeq) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
||||||
|
|
||||||
@@ -136,89 +250,151 @@ function handleInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
if (visibleOptions.value.length > 0) {
|
if (searchKeyword.value.trim() && options.value.length > 0) {
|
||||||
showDropdown.value = true;
|
openPanel();
|
||||||
if (highlightIndex.value < 0) {
|
if (highlightIndex.value < 0 && visibleOptions.value.length > 0) {
|
||||||
highlightIndex.value = 0;
|
highlightIndex.value = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选中一项:追加到 value,缓存名称,清空输入继续搜
|
* 把已选 ID 同步回表单 v-model
|
||||||
|
* 显式 emit:避免只改本地 ref、父级搜索条件仍是空数组
|
||||||
|
*/
|
||||||
|
function commitIds(ids: number[]) {
|
||||||
|
emits('update:value', ids);
|
||||||
|
mValue.value = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多选:追加 ID,保留关键词与面板,不关闭气泡
|
||||||
*/
|
*/
|
||||||
function selectOption(item: StoreSearchItem) {
|
function selectOption(item: StoreSearchItem) {
|
||||||
if (selectedIds.value.includes(item.id)) return;
|
if (selectedIds.value.includes(item.id)) return;
|
||||||
selectedMap.value = { ...selectedMap.value, [item.id]: item };
|
selectedMap.value = { ...selectedMap.value, [item.id]: item };
|
||||||
mValue.value = [...selectedIds.value, item.id];
|
commitIds([...selectedIds.value, item.id]);
|
||||||
searchKeyword.value = '';
|
// 高亮下一项(列表已剔除刚选的)
|
||||||
options.value = [];
|
nextTick(() => {
|
||||||
showDropdown.value = false;
|
if (visibleOptions.value.length === 0) {
|
||||||
highlightIndex.value = -1;
|
highlightIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
highlightIndex.value = Math.min(
|
||||||
|
Math.max(highlightIndex.value, 0),
|
||||||
|
visibleOptions.value.length - 1,
|
||||||
|
);
|
||||||
|
updatePanelPlacement();
|
||||||
|
scrollHighlightIntoView();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消某个已选门店
|
|
||||||
*/
|
|
||||||
function removeSelected(id: number) {
|
function removeSelected(id: number) {
|
||||||
mValue.value = selectedIds.value.filter((x) => x !== id);
|
commitIds(selectedIds.value.filter((x) => x !== id));
|
||||||
const next = { ...selectedMap.value };
|
const next = { ...selectedMap.value };
|
||||||
delete next[id];
|
delete next[id];
|
||||||
selectedMap.value = next;
|
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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (!showDropdown.value || visibleOptions.value.length === 0) return;
|
// 气泡打开时回车是选用,不能冒泡给外层搜索表单(否则会带着选店前的旧值提交)
|
||||||
|
if (e.key === 'Enter' && showDropdown.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!showDropdown.value || visibleOptions.value.length === 0) {
|
||||||
|
if (e.key === 'Escape') closePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const len = visibleOptions.value.length;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightIndex.value = Math.min(
|
// 头尾循环:最后一项再按下 → 回到第一项
|
||||||
Math.max(highlightIndex.value, 0) + 1,
|
highlightIndex.value =
|
||||||
visibleOptions.value.length - 1,
|
highlightIndex.value < 0 ? 0 : (highlightIndex.value + 1) % len;
|
||||||
);
|
scrollHighlightIntoView();
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
|
// 头尾循环:第一项再按上 → 跳到最后一项
|
||||||
|
highlightIndex.value =
|
||||||
|
highlightIndex.value <= 0 ? len - 1 : highlightIndex.value - 1;
|
||||||
|
scrollHighlightIntoView();
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
|
// 面板打开时回车只选用,必须 stop 否则冒泡到搜索表单会带着旧 store_ids 提交
|
||||||
|
if (showDropdown.value) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
{
|
e.stopPropagation();
|
||||||
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
|
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
|
||||||
const item = visibleOptions.value[idx];
|
const item = visibleOptions.value[idx];
|
||||||
if (item) selectOption(item);
|
if (item) selectOption(item);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
showDropdown.value = false;
|
e.preventDefault();
|
||||||
highlightIndex.value = -1;
|
closePanel();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
if (!showDropdown.value) return;
|
||||||
showDropdown.value = false;
|
const t = e.target as Node;
|
||||||
highlightIndex.value = -1;
|
if (containerRef.value?.contains(t) || panelRef.value?.contains(t)) return;
|
||||||
|
closePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onWinChange() {
|
||||||
|
if (showDropdown.value) updatePanelPlacement();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('resize', onWinChange);
|
||||||
|
window.addEventListener('scroll', onWinChange, true);
|
||||||
mergeInitialItems(props.initialItems || []);
|
mergeInitialItems(props.initialItems || []);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('resize', onWinChange);
|
||||||
|
window.removeEventListener('scroll', onWinChange, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.storeType,
|
() => props.storeType,
|
||||||
() => {
|
() => {
|
||||||
options.value = [];
|
options.value = [];
|
||||||
showDropdown.value = false;
|
closePanel();
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
highlightIndex.value = -1;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,6 +403,17 @@ watch(
|
|||||||
(items) => mergeInitialItems(items || []),
|
(items) => mergeInitialItems(items || []),
|
||||||
{ deep: true },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -246,20 +433,35 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</Input>
|
</Input>
|
||||||
|
|
||||||
<!-- 气泡建议列表 -->
|
<Teleport to="body">
|
||||||
<div v-if="showDropdown" class="store-multi-search__dropdown">
|
<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">
|
<Spin :spinning="loading">
|
||||||
<Empty
|
<Empty
|
||||||
v-if="!loading && visibleOptions.length === 0"
|
v-if="!loading && visibleOptions.length === 0"
|
||||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||||
description="暂无匹配门店"
|
description="暂无匹配门店(或已全部选中)"
|
||||||
class="py-3"
|
class="py-3"
|
||||||
/>
|
/>
|
||||||
<div
|
<div ref="listRef" class="store-multi-search__list">
|
||||||
|
<button
|
||||||
v-for="(item, index) in visibleOptions"
|
v-for="(item, index) in visibleOptions"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
class="store-multi-search__option"
|
class="store-multi-search__option"
|
||||||
:class="{ 'is-active': index === highlightIndex }"
|
:class="{ 'is-active': index === highlightIndex }"
|
||||||
|
:data-store-opt-index="index"
|
||||||
@mousedown.prevent="selectOption(item)"
|
@mousedown.prevent="selectOption(item)"
|
||||||
>
|
>
|
||||||
<div class="store-multi-search__option-name">
|
<div class="store-multi-search__option-name">
|
||||||
@@ -268,11 +470,13 @@ watch(
|
|||||||
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
|
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
|
||||||
首拼:{{ item.shouzimu }}
|
首拼:{{ item.shouzimu }}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- 已选 Tag -->
|
|
||||||
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
|
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
|
||||||
<Tag
|
<Tag
|
||||||
v-for="item in selectedItems"
|
v-for="item in selectedItems"
|
||||||
@@ -280,48 +484,127 @@ watch(
|
|||||||
closable
|
closable
|
||||||
color="blue"
|
color="blue"
|
||||||
@close="() => removeSelected(item.id)"
|
@close="() => removeSelected(item.id)"
|
||||||
|
>
|
||||||
|
<!-- 文本单独包一层才能做省略:antd Tag 的内容是裸文本节点,无法直接限宽 -->
|
||||||
|
<span
|
||||||
|
class="store-multi-search__tag-text"
|
||||||
|
:title="`${item.name}【${item.id}】`"
|
||||||
>
|
>
|
||||||
{{ item.name }}【{{ item.id }}】
|
{{ item.name }}【{{ item.id }}】
|
||||||
|
</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
/* 只用框架主题变量,随亮暗/主题色自动适配 */
|
|
||||||
.store-multi-search {
|
.store-multi-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-multi-search__dropdown {
|
.store-multi-search__tags {
|
||||||
position: absolute;
|
display: flex;
|
||||||
z-index: 1050;
|
flex-wrap: wrap;
|
||||||
top: calc(100% + 4px);
|
gap: 6px;
|
||||||
left: 0;
|
margin-top: 8px;
|
||||||
right: 0;
|
|
||||||
max-height: 280px;
|
/* 单个超长门店名不能撑破容器:限宽 + 省略号,完整名走 title 悬停提示 */
|
||||||
overflow-y: auto;
|
:deep(.ant-tag) {
|
||||||
padding: 6px;
|
display: inline-flex;
|
||||||
border-radius: 8px;
|
align-items: center;
|
||||||
background: hsl(var(--card));
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
/* 间距交给 gap 控制,去掉 antd 自带右外边距避免换行时右侧不齐 */
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-tag-close-icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-multi-search__tag-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</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));
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
0 8px 24px hsl(0 0% 0% / 0.12),
|
||||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
0 2px 8px hsl(0 0% 0% / 0.06);
|
||||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
}
|
||||||
|
|
||||||
|
.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 {
|
.store-multi-search__option {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.is-active {
|
|
||||||
background: hsl(var(--accent-hover));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-multi-search__option:hover,
|
||||||
|
.store-multi-search__option.is-active {
|
||||||
|
background: hsl(var(--accent) / 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-multi-search__option-name {
|
.store-multi-search__option-name {
|
||||||
@@ -336,11 +619,4 @@ watch(
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-multi-search__tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ onBeforeUnmount(() => {
|
|||||||
:title="previewTitle"
|
:title="previewTitle"
|
||||||
:footer="null"
|
:footer="null"
|
||||||
width="80%"
|
width="80%"
|
||||||
|
:z-index="4000"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@cancel="onPreviewClose"
|
@cancel="onPreviewClose"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export type CustomComponentType =
|
|||||||
| 'ApiSelect'
|
| 'ApiSelect'
|
||||||
| 'ApiTreeSelect'
|
| 'ApiTreeSelect'
|
||||||
| 'IconPicker'
|
| 'IconPicker'
|
||||||
|
| 'SearchTimeRangePicker'
|
||||||
| 'StoreMultiSearch'
|
| 'StoreMultiSearch'
|
||||||
| 'WarehouseAdminDrugSearch';
|
| 'WarehouseAdminDrugSearch';
|
||||||
|
|||||||
@@ -666,12 +666,12 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
//background: #ffffff;
|
/* background: #ffffff; */
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.loading-wrapper--dark {
|
.loading-wrapper--dark {
|
||||||
//background: #1e2227;
|
/* background: #1e2227; */
|
||||||
}
|
}
|
||||||
.loading-canvas {
|
.loading-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 锁店 / 查看合并:日账单 + 下属商品订单(只读)
|
||||||
|
* 按账单日用 Tabs 切换,避免多日堆叠难扫
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Button, Spin, Tabs, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import DailyChangeLogDrawer from '#/views/finance/public-account-pay/components/daily-change-log-drawer.vue';
|
||||||
|
import { getPublicAccountDailyLockOrders } from '#/views/finance/public-account-pay/api';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
mergeId?: number;
|
||||||
|
dailyId?: number;
|
||||||
|
overdueDailyIds?: number[];
|
||||||
|
/** 锁店内页要返回;弹窗内隐藏 */
|
||||||
|
showBack?: boolean;
|
||||||
|
/** 弹窗内铺满宽度 */
|
||||||
|
embedded?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
back: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showBackBtn = computed(() => props.showBack !== false);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref('');
|
||||||
|
const bundle = ref<Record<string, any> | null>(null);
|
||||||
|
/** 当前选中的日账单 id(Tabs) */
|
||||||
|
const activeDayId = ref<string>('');
|
||||||
|
const changeLogOpen = ref(false);
|
||||||
|
|
||||||
|
const days = computed(() => {
|
||||||
|
const list = bundle.value?.items;
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeDay = computed(() => {
|
||||||
|
const id = Number(activeDayId.value || 0);
|
||||||
|
if (id < 1) {
|
||||||
|
return days.value[0] || null;
|
||||||
|
}
|
||||||
|
return days.value.find((d) => Number(d.id) === id) || days.value[0] || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = computed(() => {
|
||||||
|
const b = bundle.value;
|
||||||
|
if (!b) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
range: b.bill_date_range_txt || '',
|
||||||
|
billCount: Number(b.bill_count || 0),
|
||||||
|
orderCount: Number(b.order_count || 0),
|
||||||
|
amount: b.platform_amount_total || '0.00',
|
||||||
|
periodEnd: b.period_end_at_txt || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function dayDateTxt(day: Record<string, any>) {
|
||||||
|
return day.bill_date_txt || String(day.bill_date || '—');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 标题:月-日,短一点好扫 */
|
||||||
|
function dayTabLabel(day: Record<string, any>) {
|
||||||
|
const txt = dayDateTxt(day);
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(txt)) {
|
||||||
|
return txt.slice(5);
|
||||||
|
}
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayOrderCount(day: Record<string, any>) {
|
||||||
|
return (day.items || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = '';
|
||||||
|
try {
|
||||||
|
const data = await getPublicAccountDailyLockOrders({
|
||||||
|
merge_id: Number(props.mergeId || 0) || undefined,
|
||||||
|
daily_id: Number(props.dailyId || 0) || undefined,
|
||||||
|
overdue_daily_ids: (props.overdueDailyIds || []).join(',') || undefined,
|
||||||
|
});
|
||||||
|
bundle.value = data && typeof data === 'object' ? data : null;
|
||||||
|
const first = days.value[0];
|
||||||
|
activeDayId.value = first ? String(Number(first.id || 0)) : '';
|
||||||
|
} catch (err: any) {
|
||||||
|
loadError.value = err?.message || '加载失败';
|
||||||
|
bundle.value = null;
|
||||||
|
activeDayId.value = '';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadOrders();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
Number(props.mergeId || 0),
|
||||||
|
Number(props.dailyId || 0),
|
||||||
|
(props.overdueDailyIds || []).join(','),
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
void loadOrders();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pap-orders" :class="{ 'is-embedded': embedded }">
|
||||||
|
<div class="pap-orders-head">
|
||||||
|
<div class="pap-orders-head-main">
|
||||||
|
<div class="pap-orders-badge">只读明细</div>
|
||||||
|
<h3 class="pap-orders-title">合并账单明细</h3>
|
||||||
|
<div v-if="summary" class="pap-orders-stats">
|
||||||
|
<div class="pap-stat">
|
||||||
|
<span class="pap-stat-label">账单区间</span>
|
||||||
|
<strong class="pap-stat-value">{{ summary.range || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="pap-stat">
|
||||||
|
<span class="pap-stat-label">日账单</span>
|
||||||
|
<strong class="pap-stat-value">{{ summary.billCount }} 张</strong>
|
||||||
|
</div>
|
||||||
|
<div class="pap-stat">
|
||||||
|
<span class="pap-stat-label">商品订单</span>
|
||||||
|
<strong class="pap-stat-value">{{ summary.orderCount }} 笔</strong>
|
||||||
|
</div>
|
||||||
|
<div class="pap-stat pap-stat-amount">
|
||||||
|
<span class="pap-stat-label">合计应付款</span>
|
||||||
|
<strong class="pap-stat-value">¥{{ summary.amount }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="summary?.periodEnd" class="pap-orders-deadline">
|
||||||
|
周期截止 {{ summary.periodEnd }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pap-orders-actions">
|
||||||
|
<Button
|
||||||
|
v-if="showBackBtn"
|
||||||
|
size="middle"
|
||||||
|
@click="emit('back')"
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="Number(mergeId || 0) > 0 || Number(dailyId || 0) > 0"
|
||||||
|
size="middle"
|
||||||
|
@click="changeLogOpen = true"
|
||||||
|
>
|
||||||
|
变动记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DailyChangeLogDrawer
|
||||||
|
v-model:open="changeLogOpen"
|
||||||
|
:daily-bill-id="Number(dailyId || 0)"
|
||||||
|
:merge-id="Number(mergeId || 0)"
|
||||||
|
/>
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<p v-if="loadError" class="pap-orders-empty">{{ loadError }}</p>
|
||||||
|
<p v-else-if="!loading && !days.length" class="pap-orders-empty">
|
||||||
|
暂无日账单明细
|
||||||
|
</p>
|
||||||
|
<div v-else class="pap-orders-body">
|
||||||
|
<Tabs
|
||||||
|
v-model:active-key="activeDayId"
|
||||||
|
class="pap-day-tabs"
|
||||||
|
type="card"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Tabs.TabPane
|
||||||
|
v-for="day in days"
|
||||||
|
:key="String(Number(day.id || 0))"
|
||||||
|
>
|
||||||
|
<template #tab>
|
||||||
|
<span class="pap-day-tab">
|
||||||
|
<span class="pap-day-tab-date">{{ dayTabLabel(day) }}</span>
|
||||||
|
<span class="pap-day-tab-amt">¥{{ day.platform_amount || '0.00' }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
<div v-if="activeDay" class="pap-day-panel">
|
||||||
|
<div class="pap-day-panel-head">
|
||||||
|
<div class="pap-day-panel-title">
|
||||||
|
<strong>{{ dayDateTxt(activeDay) }}</strong>
|
||||||
|
<Tag>{{ activeDay.status_txt || '—' }}</Tag>
|
||||||
|
</div>
|
||||||
|
<div class="pap-day-panel-meta">
|
||||||
|
<span>{{ dayOrderCount(activeDay) }} 笔订单</span>
|
||||||
|
<span>应付款 ¥{{ activeDay.platform_amount || '0.00' }}</span>
|
||||||
|
<span v-if="activeDay.order_amount">
|
||||||
|
订单合计 ¥{{ activeDay.order_amount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!(activeDay.items || []).length"
|
||||||
|
class="pap-order-empty"
|
||||||
|
>
|
||||||
|
该日暂无商品订单
|
||||||
|
</div>
|
||||||
|
<div v-else class="pap-order-list">
|
||||||
|
<div
|
||||||
|
v-for="order in activeDay.items || []"
|
||||||
|
:key="order.id"
|
||||||
|
class="pap-order-row"
|
||||||
|
>
|
||||||
|
<div class="pap-order-top">
|
||||||
|
<div class="pap-order-main">
|
||||||
|
<span class="pap-order-tag">商品订单</span>
|
||||||
|
<strong>{{ order.order_no || `#${order.order_id}` }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="pap-order-side">
|
||||||
|
<span>订单 ¥{{ order.order_amount || '0.00' }}</span>
|
||||||
|
<strong>平台 ¥{{ order.platform_amount || '0.00' }}</strong>
|
||||||
|
<span>{{ order.status_txt || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(order.products || []).length"
|
||||||
|
class="pap-order-products"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="product in order.products"
|
||||||
|
:key="product.id"
|
||||||
|
class="pap-product"
|
||||||
|
:class="{ 'is-chinese': Number(product.is_chinese) === 1 }"
|
||||||
|
>
|
||||||
|
<template v-if="Number(product.is_chinese) === 1">
|
||||||
|
<span class="pap-product-chip">
|
||||||
|
{{ product.drug_name || '—' }}×{{ product.number ?? 0 }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<img
|
||||||
|
v-if="product.drug_image"
|
||||||
|
class="pap-product-img"
|
||||||
|
:src="product.drug_image"
|
||||||
|
:alt="product.drug_name || '药品'"
|
||||||
|
/>
|
||||||
|
<div v-else class="pap-product-img is-empty">药</div>
|
||||||
|
<div class="pap-product-meta">
|
||||||
|
<strong>{{ product.drug_name || '—' }}</strong>
|
||||||
|
<span>×{{ product.number ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="pap-order-empty-products">暂无商品明细</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pap-orders {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.pap-orders.is-embedded {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pap-orders-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.pap-orders-head-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.pap-orders-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--pap-accent, var(--primary)));
|
||||||
|
border: 1px solid hsl(var(--pap-accent, var(--primary)) / 40%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--pap-accent, var(--primary)) / 12%);
|
||||||
|
}
|
||||||
|
.pap-orders-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: var(--pap-fs-title, 20px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-orders-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pap-stat {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--muted) / 0.22);
|
||||||
|
}
|
||||||
|
.pap-stat-amount {
|
||||||
|
border-color: hsl(var(--pap-accent, var(--primary)) / 30%);
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent, var(--primary)) / 14%);
|
||||||
|
}
|
||||||
|
.pap-stat-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-stat-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.pap-stat-amount .pap-stat-value {
|
||||||
|
color: hsl(var(--pap-accent, var(--primary)));
|
||||||
|
}
|
||||||
|
.pap-orders-deadline {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-orders-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pap-orders-empty {
|
||||||
|
margin: 24px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pap-orders-body {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pap-day-tabs {
|
||||||
|
padding: 8px 8px 0;
|
||||||
|
background: hsl(var(--muted) / 0.18);
|
||||||
|
}
|
||||||
|
.pap-day-tabs :deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.pap-day-tabs :deep(.ant-tabs-tab) {
|
||||||
|
padding: 6px 10px !important;
|
||||||
|
}
|
||||||
|
.pap-day-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.pap-day-tab-date {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-day-tab-amt {
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-day-panel {
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
.pap-day-panel-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
.pap-day-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pap-day-panel-title strong {
|
||||||
|
font-size: 15px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-day-panel-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-order-empty {
|
||||||
|
padding: 16px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pap-order-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.pap-order-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--muted) / 0.18);
|
||||||
|
}
|
||||||
|
.pap-order-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pap-order-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pap-order-tag {
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--pap-accent, var(--primary)));
|
||||||
|
border: 1px solid hsl(var(--pap-accent, var(--primary)) / 30%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--pap-accent, var(--primary)) / 10%);
|
||||||
|
}
|
||||||
|
.pap-order-main strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-order-side {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-order-side strong {
|
||||||
|
color: hsl(var(--pap-accent, var(--primary)));
|
||||||
|
}
|
||||||
|
.pap-order-products {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.pap-product {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: calc(33.333% - 4px);
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: hsl(var(--background) / 0.55);
|
||||||
|
border: 1px solid hsl(var(--border) / 70%);
|
||||||
|
}
|
||||||
|
.pap-product.is-chinese {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: hsl(var(--muted) / 0.28);
|
||||||
|
}
|
||||||
|
.pap-product-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pap-product-img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.35);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pap-product-img.is-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-product-meta {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.pap-product-meta strong {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pap-product-meta span {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-order-empty-products {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.pap-orders-stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.pap-orders-head {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.pap-orders-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pap-product:not(.is-chinese) {
|
||||||
|
width: calc(50% - 3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 锁框内上传凭证:与锁定提示同一块面板切换,不用弹窗/抽屉/整页
|
||||||
|
*/
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Button } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import DailyUploadForm from '#/views/finance/public-account-pay/components/daily-upload-form.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dailyId: number;
|
||||||
|
storeName?: string;
|
||||||
|
platformAmount?: string;
|
||||||
|
billDateTxt?: string;
|
||||||
|
unlockMode?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
back: [];
|
||||||
|
waiting: [payload?: {
|
||||||
|
bill_status?: number;
|
||||||
|
contact_phones?: { name?: string; phone: string }[];
|
||||||
|
}];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof DailyUploadForm>>();
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const unlockHint = props.unlockMode === 'voucher'
|
||||||
|
? '提交成功后立刻解锁,接诊和开方可继续。'
|
||||||
|
: '提交后仍锁定,需平台管理员确认到账后才会解锁。';
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
formRef.value?.load({
|
||||||
|
id: props.dailyId,
|
||||||
|
store_name: props.storeName,
|
||||||
|
platform_amount: props.platformAmount,
|
||||||
|
bill_date_txt: props.billDateTxt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 可无附件直接声明已支付;若已选文件一并提交 */
|
||||||
|
async function onDeclarePaid() {
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await formRef.value?.submit({ allowEmpty: true });
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFormSuccess(payload: {
|
||||||
|
unlocked: boolean;
|
||||||
|
bill_status?: number;
|
||||||
|
contact_phones?: { name?: string; phone: string }[];
|
||||||
|
}) {
|
||||||
|
if (!payload.unlocked) {
|
||||||
|
emit('waiting', payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pap-lock-pay">
|
||||||
|
<div class="pap-lock-badge">上传付款凭证</div>
|
||||||
|
<h2 class="pap-lock-title">处理逾期日账单</h2>
|
||||||
|
<p class="pap-lock-desc">{{ unlockHint }}</p>
|
||||||
|
<DailyUploadForm
|
||||||
|
ref="formRef"
|
||||||
|
:preview-z-index="4000"
|
||||||
|
allow-empty-voucher
|
||||||
|
@success="onFormSuccess"
|
||||||
|
/>
|
||||||
|
<div class="pap-lock-pay-actions">
|
||||||
|
<Button size="large" class="pap-lock-btn is-ghost" @click="emit('back')">返回说明</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="pap-lock-btn"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="onDeclarePaid"
|
||||||
|
>
|
||||||
|
我已支付
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="pap-lock-pay-tip">
|
||||||
|
已选凭证会一并提交;未上传也可直接点「我已支付」。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pap-lock-pay {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.pap-lock-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
margin-bottom: var(--pap-gap-sm, 14px);
|
||||||
|
font-size: var(--pap-fs-badge, 12px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 40%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--pap-accent) / 12%);
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-title {
|
||||||
|
margin: 0 0 var(--pap-gap-sm, 12px);
|
||||||
|
font-size: var(--pap-fs-title, 28px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-desc {
|
||||||
|
margin: 0 0 var(--pap-gap-md, 20px);
|
||||||
|
font-size: var(--pap-fs-desc, 15px);
|
||||||
|
line-height: 1.55;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-pay-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--pap-gap-sm, 12px);
|
||||||
|
margin-top: var(--pap-gap-md, 20px);
|
||||||
|
}
|
||||||
|
.pap-lock-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--pap-btn-h, 48px);
|
||||||
|
font-size: var(--pap-fs-btn, 16px);
|
||||||
|
}
|
||||||
|
.pap-lock-btn.is-ghost {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-color: hsl(var(--pap-accent) / 40%);
|
||||||
|
}
|
||||||
|
.pap-lock-pay-tip {
|
||||||
|
margin: var(--pap-gap-sm, 12px) 0 0;
|
||||||
|
font-size: var(--pap-fs-label, 12px);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.pap-lock-pay-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 锁框内等待审核:催办发 OA,展示配置里允许拨打的管理员手机号
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Button, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { urgePublicAccountDailyBill } from '#/views/finance/public-account-pay/api';
|
||||||
|
|
||||||
|
import type { PublicAccountLockContact } from '#/utils/publicAccountLock';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dailyId: number;
|
||||||
|
platformAmount?: string;
|
||||||
|
billDateTxt?: string;
|
||||||
|
contacts?: PublicAccountLockContact[];
|
||||||
|
canPay?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reupload: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const urging = ref(false);
|
||||||
|
|
||||||
|
const phoneList = computed(() =>
|
||||||
|
(props.contacts || [])
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
dial: String(item.phone_txt || item.tel || item.phone || '').replace(/[^\d]/g, ''),
|
||||||
|
}))
|
||||||
|
.filter((item) => /^1\d{10}$/.test(item.dial)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 催办:后端限流 5 分钟,失败文案直接展示 */
|
||||||
|
async function onUrge() {
|
||||||
|
if (props.dailyId < 1) {
|
||||||
|
message.warning('暂无待审核日账单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (urging.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
urging.value = true;
|
||||||
|
try {
|
||||||
|
await urgePublicAccountDailyBill({ id: props.dailyId });
|
||||||
|
message.success('已向管理员发送催办');
|
||||||
|
} finally {
|
||||||
|
urging.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function callPhone(phone: string) {
|
||||||
|
window.open(`tel:${phone}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pap-lock-wait">
|
||||||
|
<div class="pap-lock-badge">等待管理员审核</div>
|
||||||
|
<h2 class="pap-lock-title">凭证已提交</h2>
|
||||||
|
<p class="pap-lock-desc">
|
||||||
|
平台管理员确认到账后自动解锁。可催办,或拨打下方号码联系管理员。
|
||||||
|
</p>
|
||||||
|
<div class="pap-lock-stat">
|
||||||
|
<div class="pap-lock-stat-item">
|
||||||
|
<span class="pap-lock-stat-label">账单区间</span>
|
||||||
|
<strong>{{ billDateTxt || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="pap-lock-stat-item">
|
||||||
|
<span class="pap-lock-stat-label">合计应付款</span>
|
||||||
|
<strong class="is-amount">¥{{ platformAmount || '0.00' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="phoneList.length" class="pap-lock-phones">
|
||||||
|
<div class="pap-lock-phones-title">联系管理员</div>
|
||||||
|
<button
|
||||||
|
v-for="item in phoneList"
|
||||||
|
:key="item.dial"
|
||||||
|
type="button"
|
||||||
|
class="pap-lock-phone"
|
||||||
|
@click="callPhone(item.dial)"
|
||||||
|
>
|
||||||
|
<span>{{ item.name || '管理员' }}</span>
|
||||||
|
<strong>{{ item.dial }}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="pap-lock-empty">暂未配置可拨打的管理员号码,请使用催办。</p>
|
||||||
|
<div class="pap-lock-pay-actions" :class="{ 'is-single': !canPay }">
|
||||||
|
<Button
|
||||||
|
v-if="canPay"
|
||||||
|
size="large"
|
||||||
|
class="pap-lock-btn is-ghost"
|
||||||
|
@click="emit('reupload')"
|
||||||
|
>
|
||||||
|
重新上传
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="pap-lock-btn"
|
||||||
|
:loading="urging"
|
||||||
|
@click="onUrge"
|
||||||
|
>
|
||||||
|
催办
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pap-lock-wait {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.pap-lock-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
margin-bottom: var(--pap-gap-sm, 14px);
|
||||||
|
font-size: var(--pap-fs-badge, 12px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 40%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--pap-accent) / 12%);
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-title {
|
||||||
|
margin: 0 0 var(--pap-gap-sm, 12px);
|
||||||
|
font-size: var(--pap-fs-title, 28px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-desc {
|
||||||
|
margin: 0 0 var(--pap-gap-md, 20px);
|
||||||
|
font-size: var(--pap-fs-desc, 15px);
|
||||||
|
line-height: 1.55;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-stat {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--pap-stat-cols, 1fr 1fr);
|
||||||
|
gap: var(--pap-gap-sm, 16px);
|
||||||
|
margin-bottom: var(--pap-gap-md, 16px);
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--pap-block-pad, 16px) calc(var(--pap-block-pad, 16px) + 2px);
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 30%);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-stat-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--pap-gap-xs, 6px);
|
||||||
|
font-size: var(--pap-fs-label, 12px);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item strong {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--pap-fs-stat, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item strong.is-amount {
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
}
|
||||||
|
.pap-lock-phones {
|
||||||
|
padding: var(--pap-block-pad, 16px) calc(var(--pap-block-pad, 16px) + 2px);
|
||||||
|
margin-bottom: var(--pap-gap-md, 20px);
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 30%);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-phones-title {
|
||||||
|
margin-bottom: var(--pap-gap-xs, 10px);
|
||||||
|
font-size: var(--pap-fs-flow, 13px);
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
}
|
||||||
|
.pap-lock-phone {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: var(--pap-fs-desc, 14px);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.pap-lock-phone:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.pap-lock-phone strong {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
.pap-lock-empty {
|
||||||
|
margin: 0 0 var(--pap-gap-md, 20px);
|
||||||
|
font-size: var(--pap-fs-flow, 13px);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-pay-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--pap-gap-sm, 12px);
|
||||||
|
}
|
||||||
|
.pap-lock-pay-actions.is-single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.pap-lock-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--pap-btn-h, 48px);
|
||||||
|
font-size: var(--pap-fs-btn, 16px);
|
||||||
|
}
|
||||||
|
.pap-lock-btn.is-ghost {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-color: hsl(var(--pap-accent) / 40%);
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.pap-lock-pay-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 公账逾期弹窗背景:警戒条、脉冲环、余烬粒子
|
||||||
|
* 跟随亮/暗主题,弹窗关闭即停 RAF,避免后台空转
|
||||||
|
*/
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
active?: boolean;
|
||||||
|
/** 后台配置:blue / yellow / red */
|
||||||
|
color?: 'blue' | 'yellow' | 'red';
|
||||||
|
/** 上传/等待页只保留上下警戒条,避免三角和表单叠在一起 */
|
||||||
|
tapesOnly?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
interface Ember {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
life: number;
|
||||||
|
maxLife: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationId = 0;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let themeObserver: MutationObserver | null = null;
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
function isDarkTheme() {
|
||||||
|
return (
|
||||||
|
document.documentElement.classList.contains('dark') ||
|
||||||
|
document.documentElement.getAttribute('data-theme') === 'dark'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoop() {
|
||||||
|
if (running || !containerRef.value || !canvasRef.value) return;
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!ctx) return;
|
||||||
|
running = true;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
let cssW = 0;
|
||||||
|
let cssH = 0;
|
||||||
|
let last = 0;
|
||||||
|
let elapsed = 0;
|
||||||
|
let embers: Ember[] = [];
|
||||||
|
const MAX_EMBERS = 22;
|
||||||
|
|
||||||
|
function updateSize() {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
cssW = rect.width || 760;
|
||||||
|
cssH = rect.height || 460;
|
||||||
|
const pw = Math.round(cssW * dpr);
|
||||||
|
const ph = Math.round(cssH * dpr);
|
||||||
|
if (canvas.width !== pw || canvas.height !== ph) {
|
||||||
|
canvas.width = pw;
|
||||||
|
canvas.height = ph;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function theme() {
|
||||||
|
const dark = isDarkTheme();
|
||||||
|
const color = props.color === 'blue' || props.color === 'red' ? props.color : 'yellow';
|
||||||
|
if (color === 'blue') {
|
||||||
|
return {
|
||||||
|
dark,
|
||||||
|
warn: dark ? { r: 96, g: 165, b: 250 } : { r: 37, g: 99, b: 235 },
|
||||||
|
tape: dark ? { r: 59, g: 130, b: 246 } : { r: 37, g: 99, b: 235 },
|
||||||
|
ink: dark ? { r: 15, g: 23, b: 42 } : { r: 15, g: 23, b: 42 },
|
||||||
|
bg: dark ? 'rgba(10, 18, 32, 0.55)' : 'rgba(239, 246, 255, 0.42)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (color === 'red') {
|
||||||
|
return {
|
||||||
|
dark,
|
||||||
|
warn: dark ? { r: 248, g: 113, b: 113 } : { r: 220, g: 38, b: 38 },
|
||||||
|
tape: dark ? { r: 239, g: 68, b: 68 } : { r: 220, g: 38, b: 38 },
|
||||||
|
ink: dark ? { r: 23, g: 15, b: 15 } : { r: 28, g: 25, b: 23 },
|
||||||
|
bg: dark ? 'rgba(32, 10, 10, 0.55)' : 'rgba(254, 242, 242, 0.42)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dark,
|
||||||
|
warn: dark ? { r: 251, g: 191, b: 36 } : { r: 217, g: 119, b: 6 },
|
||||||
|
tape: dark ? { r: 245, g: 158, b: 11 } : { r: 217, g: 119, b: 6 },
|
||||||
|
ink: dark ? { r: 15, g: 23, b: 42 } : { r: 28, g: 25, b: 23 },
|
||||||
|
bg: dark ? 'rgba(23, 18, 10, 0.55)' : 'rgba(255, 251, 235, 0.35)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnEmber(): Ember {
|
||||||
|
return {
|
||||||
|
x: Math.random() * cssW,
|
||||||
|
y: cssH * (0.35 + Math.random() * 0.5),
|
||||||
|
vx: (Math.random() - 0.5) * 18,
|
||||||
|
vy: -12 - Math.random() * 22,
|
||||||
|
life: 1,
|
||||||
|
maxLife: 1.6 + Math.random() * 2.2,
|
||||||
|
size: 1.2 + Math.random() * 2.4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTape(
|
||||||
|
c: CanvasRenderingContext2D,
|
||||||
|
y: number,
|
||||||
|
h: number,
|
||||||
|
offset: number,
|
||||||
|
t: ReturnType<typeof theme>,
|
||||||
|
) {
|
||||||
|
c.save();
|
||||||
|
c.beginPath();
|
||||||
|
c.rect(0, y, cssW, h);
|
||||||
|
c.clip();
|
||||||
|
c.fillStyle = `rgb(${t.tape.r},${t.tape.g},${t.tape.b})`;
|
||||||
|
c.fillRect(0, y, cssW, h);
|
||||||
|
c.fillStyle = `rgba(${t.ink.r},${t.ink.g},${t.ink.b},0.82)`;
|
||||||
|
const stripe = 16;
|
||||||
|
const shift = offset % (stripe * 2);
|
||||||
|
for (let x = -h - stripe * 2 + shift; x < cssW + h; x += stripe * 2) {
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(x, y);
|
||||||
|
c.lineTo(x + stripe, y);
|
||||||
|
c.lineTo(x + stripe + h, y + h);
|
||||||
|
c.lineTo(x + h, y + h);
|
||||||
|
c.closePath();
|
||||||
|
c.fill();
|
||||||
|
}
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTriangle(
|
||||||
|
c: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
size: number,
|
||||||
|
pulse: number,
|
||||||
|
t: ReturnType<typeof theme>,
|
||||||
|
) {
|
||||||
|
const s = size * pulse;
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.55)`;
|
||||||
|
c.shadowBlur = 22;
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(cx, cy - s);
|
||||||
|
c.lineTo(cx + s * 0.92, cy + s * 0.78);
|
||||||
|
c.lineTo(cx - s * 0.92, cy + s * 0.78);
|
||||||
|
c.closePath();
|
||||||
|
c.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.16)`;
|
||||||
|
c.fill();
|
||||||
|
c.lineWidth = 3;
|
||||||
|
c.strokeStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.95)`;
|
||||||
|
c.stroke();
|
||||||
|
c.shadowBlur = 0;
|
||||||
|
c.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.95)`;
|
||||||
|
c.beginPath();
|
||||||
|
c.roundRect(cx - 3, cy - s * 0.28, 6, s * 0.52, 2);
|
||||||
|
c.fill();
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(cx, cy + s * 0.42, 4, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(ts: number) {
|
||||||
|
if (!running) return;
|
||||||
|
updateSize();
|
||||||
|
if (cssW <= 0 || cssH <= 0) {
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!last) last = ts;
|
||||||
|
let dt = (ts - last) / 1000;
|
||||||
|
if (dt <= 0) dt = 0.016;
|
||||||
|
if (dt > 0.2) dt = 0.2;
|
||||||
|
last = ts;
|
||||||
|
elapsed += dt;
|
||||||
|
const t = theme();
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.fillStyle = t.bg;
|
||||||
|
ctx.fillRect(0, 0, cssW, cssH);
|
||||||
|
if (!props.tapesOnly) {
|
||||||
|
// 三角/光环随面板高度缩放,避免矮屏占满上半区把正文挤乱
|
||||||
|
const tapeReserve = Math.round(Math.min(18, Math.max(10, cssH * 0.028)));
|
||||||
|
const cy = Math.round(
|
||||||
|
Math.min(Math.max(48, tapeReserve + 28 + cssH * 0.04), cssH * 0.16),
|
||||||
|
);
|
||||||
|
const triSize = Math.round(Math.min(36, Math.max(16, cssH * 0.045)));
|
||||||
|
const ringSpan = Math.min(150, Math.max(60, cssH * 0.18));
|
||||||
|
const cx = cssW / 2;
|
||||||
|
const glow = ctx.createRadialGradient(cx, cy, 8, cx, cy, Math.max(cssW, cssH) * 0.55);
|
||||||
|
glow.addColorStop(0, `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.22)`);
|
||||||
|
glow.addColorStop(0.45, `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.06)`);
|
||||||
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, cssW, cssH);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const phase = (elapsed * 0.55 + i / 3) % 1;
|
||||||
|
const radius = triSize * 0.85 + phase * ringSpan;
|
||||||
|
const alpha = (1 - phase) * 0.35;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},${alpha})`;
|
||||||
|
ctx.lineWidth = Math.max(1.5, triSize / 28);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
const pulse = 1 + Math.sin(elapsed * 3.2) * 0.06;
|
||||||
|
drawTriangle(ctx, cx, cy, triSize, pulse, t);
|
||||||
|
while (embers.length < MAX_EMBERS) embers.push(spawnEmber());
|
||||||
|
for (let i = embers.length - 1; i >= 0; i--) {
|
||||||
|
const p = embers[i]!;
|
||||||
|
p.life -= dt / p.maxLife;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
if (p.life <= 0) {
|
||||||
|
embers.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = p.life * 0.75;
|
||||||
|
ctx.shadowColor = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.8)`;
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},${p.life})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size * Math.min(1, cssH / 520), 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tapeH = Math.round(Math.min(18, Math.max(12, cssH * 0.028)));
|
||||||
|
const shift = elapsed * 36;
|
||||||
|
drawTape(ctx, 0, tapeH, shift, t);
|
||||||
|
drawTape(ctx, cssH - tapeH, tapeH, -shift, t);
|
||||||
|
ctx.restore();
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
resizeObserver = new ResizeObserver(() => updateSize());
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
}
|
||||||
|
themeObserver = new MutationObserver(() => undefined);
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'data-theme'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoop() {
|
||||||
|
running = false;
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
animationId = 0;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
themeObserver?.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (props.active !== false) startLoop();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(active) => {
|
||||||
|
if (active === false) stopLoop();
|
||||||
|
else nextTick(() => startLoop());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopLoop();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="lock-canvas-wrap">
|
||||||
|
<canvas ref="canvasRef" class="lock-canvas" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lock-canvas-wrap,
|
||||||
|
.lock-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.lock-canvas {
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* PC 公账逾期提醒:先展示友好大条幅数秒,再收起到顶栏「传方」旁胶囊
|
||||||
|
* 「去处理」打开软处理层(上传/进度),避免已在账单页时路由跳转无感
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyPublicAccountLockSession,
|
||||||
|
dockPublicAccountBanner,
|
||||||
|
expandPublicAccountBanner,
|
||||||
|
getPublicAccountLockState,
|
||||||
|
normalizeLockAlertColor,
|
||||||
|
openPublicAccountLockHandle,
|
||||||
|
} from '#/utils/publicAccountLock';
|
||||||
|
import { getPublicAccountLockStatus } from '#/views/finance/public-account-pay/api';
|
||||||
|
|
||||||
|
const DOCK_AFTER_MS = 4500;
|
||||||
|
const lockState = getPublicAccountLockState();
|
||||||
|
const dockTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const alertColor = computed(() =>
|
||||||
|
normalizeLockAlertColor(lockState.payload.alert_color),
|
||||||
|
);
|
||||||
|
const amountTxt = computed(
|
||||||
|
() =>
|
||||||
|
lockState.payload.platform_amount_total ||
|
||||||
|
lockState.payload.platform_amount ||
|
||||||
|
'0.00',
|
||||||
|
);
|
||||||
|
const billCount = computed(() => {
|
||||||
|
const n = Number(lockState.payload.bill_count || 0);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
||||||
|
});
|
||||||
|
const rangeTxt = computed(() =>
|
||||||
|
String(lockState.payload.bill_date_range_txt || '').trim(),
|
||||||
|
);
|
||||||
|
const titleTxt = computed(() =>
|
||||||
|
billCount.value > 1 ? '有多张日账单待结清' : '有日账单待结清',
|
||||||
|
);
|
||||||
|
const descTxt = computed(() => {
|
||||||
|
if (billCount.value > 1) {
|
||||||
|
return `${rangeTxt.value || '账单区间'} · 共 ${billCount.value} 张 · 合计 ¥${amountTxt.value}`;
|
||||||
|
}
|
||||||
|
return `应付款 ¥${amountTxt.value},请尽快汇款以免影响接诊开方`;
|
||||||
|
});
|
||||||
|
const showExpanded = computed(
|
||||||
|
() => lockState.bannerVisible && !lockState.bannerDocked,
|
||||||
|
);
|
||||||
|
|
||||||
|
function clearDockTimer() {
|
||||||
|
if (dockTimer.value) {
|
||||||
|
clearTimeout(dockTimer.value);
|
||||||
|
dockTimer.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 大条幅展示数秒后收到顶栏 */
|
||||||
|
function scheduleDock() {
|
||||||
|
clearDockTimer();
|
||||||
|
if (!lockState.bannerVisible || lockState.bannerDocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dockTimer.value = setTimeout(() => {
|
||||||
|
dockPublicAccountBanner();
|
||||||
|
}, DOCK_AFTER_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBanner() {
|
||||||
|
try {
|
||||||
|
const live = await getPublicAccountLockStatus();
|
||||||
|
if (live && typeof live === 'object') {
|
||||||
|
applyPublicAccountLockSession(live);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 条幅刷新失败不挡布局
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去处理:打开锁框上传/进度(软层) */
|
||||||
|
function goHandle(e?: Event) {
|
||||||
|
e?.stopPropagation?.();
|
||||||
|
openPublicAccountLockHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExpandChip() {
|
||||||
|
expandPublicAccountBanner();
|
||||||
|
scheduleDock();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [lockState.bannerVisible, lockState.bannerDocked] as const,
|
||||||
|
([visible, docked]) => {
|
||||||
|
if (visible && !docked) {
|
||||||
|
scheduleDock();
|
||||||
|
} else {
|
||||||
|
clearDockTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refreshBanner().then(() => scheduleDock());
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearDockTimer();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 展开大条幅 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="pap-banner-fade">
|
||||||
|
<div
|
||||||
|
v-if="showExpanded"
|
||||||
|
class="pap-banner-host"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div class="pap-banner" :class="'is-' + alertColor">
|
||||||
|
<div class="pap-banner-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 3.5L21 20H3L12 3.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 9v5.5M12 16.8v.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="pap-banner-main">
|
||||||
|
<div class="pap-banner-title">{{ titleTxt }}</div>
|
||||||
|
<div class="pap-banner-desc">{{ descTxt }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pap-banner-action"
|
||||||
|
@click="goHandle"
|
||||||
|
>
|
||||||
|
去处理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pap-banner-min"
|
||||||
|
title="收到顶栏"
|
||||||
|
@click="dockPublicAccountBanner"
|
||||||
|
>
|
||||||
|
收起
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pap-banner-host {
|
||||||
|
position: fixed;
|
||||||
|
top: 64px;
|
||||||
|
right: 16px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 1090;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.pap-banner-host {
|
||||||
|
left: calc(var(--vben-sider-width, 224px) + 16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pap-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
width: min(720px, 100%);
|
||||||
|
padding: 12px 14px;
|
||||||
|
pointer-events: auto;
|
||||||
|
--pap-banner-accent: var(--warning);
|
||||||
|
border: 1px solid hsl(var(--pap-banner-accent) / 32%);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow:
|
||||||
|
0 0 6px hsl(var(--pap-banner-accent) / 16%),
|
||||||
|
0 10px 28px hsl(var(--foreground) / 8%);
|
||||||
|
}
|
||||||
|
.pap-banner.is-blue {
|
||||||
|
--pap-banner-accent: 217 91% 53%;
|
||||||
|
}
|
||||||
|
.pap-banner.is-yellow {
|
||||||
|
--pap-banner-accent: var(--warning);
|
||||||
|
}
|
||||||
|
.pap-banner.is-red {
|
||||||
|
--pap-banner-accent: 0 72% 51%;
|
||||||
|
}
|
||||||
|
.pap-banner-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: hsl(var(--pap-banner-accent));
|
||||||
|
background: hsl(var(--pap-banner-accent) / 12%);
|
||||||
|
border: 1px solid hsl(var(--pap-banner-accent) / 28%);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.pap-banner-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.pap-banner-title {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-banner-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-banner-action {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
background: hsl(var(--pap-banner-accent));
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pap-banner-action:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
.pap-banner-min {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pap-banner-min:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-color: hsl(var(--pap-banner-accent) / 40%);
|
||||||
|
}
|
||||||
|
.pap-banner-fade-enter-active,
|
||||||
|
.pap-banner-fade-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.28s ease,
|
||||||
|
transform 0.28s ease;
|
||||||
|
}
|
||||||
|
.pap-banner-fade-enter-from,
|
||||||
|
.pap-banner-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 顶栏公账逾期胶囊:大条幅收起后出现在「传方」旁,点击可展开或去处理
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expandPublicAccountBanner,
|
||||||
|
getPublicAccountLockState,
|
||||||
|
normalizeLockAlertColor,
|
||||||
|
openPublicAccountLockHandle,
|
||||||
|
} from '#/utils/publicAccountLock';
|
||||||
|
|
||||||
|
const lockState = getPublicAccountLockState();
|
||||||
|
|
||||||
|
const visible = computed(
|
||||||
|
() => lockState.bannerVisible && lockState.bannerDocked,
|
||||||
|
);
|
||||||
|
const alertColor = computed(() =>
|
||||||
|
normalizeLockAlertColor(lockState.payload.alert_color),
|
||||||
|
);
|
||||||
|
const amountTxt = computed(
|
||||||
|
() =>
|
||||||
|
lockState.payload.platform_amount_total ||
|
||||||
|
lockState.payload.platform_amount ||
|
||||||
|
'0.00',
|
||||||
|
);
|
||||||
|
const tipTxt = computed(() => `公账待结 ¥${amountTxt.value}`);
|
||||||
|
|
||||||
|
function onChipClick() {
|
||||||
|
openPublicAccountLockHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExpand(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
expandPublicAccountBanner();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="visible"
|
||||||
|
type="button"
|
||||||
|
class="pap-dock-chip"
|
||||||
|
:class="'is-' + alertColor"
|
||||||
|
:title="tipTxt"
|
||||||
|
@click="onChipClick"
|
||||||
|
>
|
||||||
|
<span class="pap-dock-dot" />
|
||||||
|
<span class="pap-dock-text">公账待结</span>
|
||||||
|
<strong class="pap-dock-amt">¥{{ amountTxt }}</strong>
|
||||||
|
<span class="pap-dock-expand" title="展开提醒" @click="onExpand">展开</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pap-dock-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 220px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px 0 10px;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
--pap-dock-accent: var(--warning);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--pap-dock-accent) / 10%);
|
||||||
|
border: 1px solid hsl(var(--pap-dock-accent) / 32%);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-dock-accent) / 14%);
|
||||||
|
}
|
||||||
|
.pap-dock-chip.is-blue {
|
||||||
|
--pap-dock-accent: 217 91% 53%;
|
||||||
|
}
|
||||||
|
.pap-dock-chip.is-yellow {
|
||||||
|
--pap-dock-accent: var(--warning);
|
||||||
|
}
|
||||||
|
.pap-dock-chip.is-red {
|
||||||
|
--pap-dock-accent: 0 72% 51%;
|
||||||
|
}
|
||||||
|
.pap-dock-chip:hover {
|
||||||
|
background: hsl(var(--pap-dock-accent) / 16%);
|
||||||
|
}
|
||||||
|
.pap-dock-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: hsl(var(--pap-dock-accent));
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--pap-dock-accent) / 22%);
|
||||||
|
}
|
||||||
|
.pap-dock-text {
|
||||||
|
color: hsl(var(--pap-dock-accent));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pap-dock-amt {
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-dock-expand {
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: 2px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: hsl(var(--background) / 60%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.pap-dock-expand:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 公账日账单逾期全屏警示:不可点遮罩/Esc/X 关闭
|
||||||
|
* 上传在同一锁框内切换展示,不用弹窗、抽屉、整页
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import { Button, Modal, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
import {
|
||||||
|
PUBLIC_ACCOUNT_LOCK_PROMPT_Z_INDEX,
|
||||||
|
clearPublicAccountLock,
|
||||||
|
closePublicAccountLockHandle,
|
||||||
|
getPublicAccountLockState,
|
||||||
|
normalizeLockAlertColor,
|
||||||
|
} from '#/utils/publicAccountLock';
|
||||||
|
import {
|
||||||
|
generatePublicAccountDailyBill,
|
||||||
|
getPublicAccountDailyBillInfo,
|
||||||
|
getPublicAccountLockStatus,
|
||||||
|
} from '#/views/finance/public-account-pay/api';
|
||||||
|
|
||||||
|
import LockPayPanel from './LockPayPanel.vue';
|
||||||
|
import LockWaitPanel from './LockWaitPanel.vue';
|
||||||
|
import LockOrdersPanel from './LockOrdersPanel.vue';
|
||||||
|
import LockWarningCanvas from './LockWarningCanvas.vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const lockState = getPublicAccountLockState();
|
||||||
|
const panelMode = ref<'hint' | 'pay' | 'wait' | 'orders'>('hint');
|
||||||
|
const payDailyId = ref(0);
|
||||||
|
const openingPay = ref(false);
|
||||||
|
const loggingOut = ref(false);
|
||||||
|
|
||||||
|
const roleId = computed(() =>
|
||||||
|
Number(userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 超管/系统管理员/诊所管理员可在锁框内上传 */
|
||||||
|
const canPay = computed(() => [1, 2, 8].includes(roleId.value));
|
||||||
|
|
||||||
|
/** 硬锁或条幅「去处理」软层 */
|
||||||
|
const overlayOpen = computed(
|
||||||
|
() => lockState.visible || lockState.handleVisible,
|
||||||
|
);
|
||||||
|
/** 软层:可返回,不是身份硬锁 */
|
||||||
|
const softHandle = computed(
|
||||||
|
() => lockState.handleVisible && !lockState.visible,
|
||||||
|
);
|
||||||
|
|
||||||
|
const billDateTxt = computed(() => {
|
||||||
|
const date = String(lockState.payload.bill_date || '');
|
||||||
|
if (date.length === 8) {
|
||||||
|
return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 多张逾期展示几号到几号;单张回落单日 */
|
||||||
|
const billDateRangeTxt = computed(() => {
|
||||||
|
const range = String(lockState.payload.bill_date_range_txt || '').trim();
|
||||||
|
if (range) {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
return billDateTxt.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const billDateLabel = computed(() =>
|
||||||
|
billCount.value > 1 ? '账单区间' : '账单日',
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountTxt = computed(
|
||||||
|
() =>
|
||||||
|
lockState.payload.platform_amount_total ||
|
||||||
|
lockState.payload.platform_amount ||
|
||||||
|
'0.00',
|
||||||
|
);
|
||||||
|
const billCount = computed(() => {
|
||||||
|
const n = Number(lockState.payload.bill_count || 0);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
||||||
|
});
|
||||||
|
const mergeHint = computed(() => {
|
||||||
|
if (billCount.value > 1) {
|
||||||
|
return `合并处理 ${billCount.value} 张(${billDateRangeTxt.value}),一次上传覆盖整段。`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const alertColor = computed(() =>
|
||||||
|
normalizeLockAlertColor(lockState.payload.alert_color),
|
||||||
|
);
|
||||||
|
const unlockMode = computed(() =>
|
||||||
|
String(lockState.payload.unlock_mode || 'confirm'),
|
||||||
|
);
|
||||||
|
const lockTime = computed(() => lockState.payload.lock_time || '09:00');
|
||||||
|
const lockDays = computed(() => {
|
||||||
|
const days = Number(lockState.payload.lock_days || 1);
|
||||||
|
return Number.isFinite(days) && days >= 1 ? Math.floor(days) : 1;
|
||||||
|
});
|
||||||
|
/** 锁店说明里的宽限文案:1 天写「次日」,多天写「后第 N 天」 */
|
||||||
|
const lockDaysLabel = computed(() =>
|
||||||
|
lockDays.value === 1 ? '次日' : `后第 ${lockDays.value} 天`,
|
||||||
|
);
|
||||||
|
const unlockByVoucher = computed(() => unlockMode.value === 'voucher');
|
||||||
|
const contactPhones = computed(() => lockState.payload.contact_phones || []);
|
||||||
|
/** 尊敬的{诊所名} */
|
||||||
|
const storeGreeting = computed(() => {
|
||||||
|
const name = String(lockState.payload.store_name || '').trim();
|
||||||
|
return name ? `尊敬的${name}` : '尊敬的诊所管理员';
|
||||||
|
});
|
||||||
|
/** 锁定原因:不重复账单区间/金额,只点明周期已到 */
|
||||||
|
const lockReasonTxt = '账单支付周期已到';
|
||||||
|
|
||||||
|
/** 日汇总已传凭证才露出进度,0 不能盖掉 3 */
|
||||||
|
const canViewProgress = computed(() => {
|
||||||
|
const payload = lockState.payload || {};
|
||||||
|
return Number(payload.status) === 3 || Number(payload.bill_status) === 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyBillStatus(status: number) {
|
||||||
|
if (![0, 1, 2, 3].includes(status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lockState.payload.status = status;
|
||||||
|
lockState.payload.bill_status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 锁缓存可能停在 0,打开锁框后用现算状态 + 日汇总详情纠正 */
|
||||||
|
async function refreshLockStatus() {
|
||||||
|
try {
|
||||||
|
const live = await getPublicAccountLockStatus();
|
||||||
|
if (live && typeof live === 'object') {
|
||||||
|
Object.assign(lockState.payload, live);
|
||||||
|
if (Number(live.status) === 3 || Number(live.bill_status) === 3) {
|
||||||
|
applyBillStatus(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 刷新失败仍用当前 payload
|
||||||
|
}
|
||||||
|
const dailyId = Number(lockState.payload.daily_id || 0);
|
||||||
|
if (dailyId < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const detail = await getPublicAccountDailyBillInfo(dailyId);
|
||||||
|
// 周期锁店:合计用 platform_amount_total,勿用单日金额覆盖
|
||||||
|
const billCount = Number(lockState.payload.bill_count || 0);
|
||||||
|
const hasPeriod =
|
||||||
|
billCount > 1 ||
|
||||||
|
Number(lockState.payload.merge_id || 0) > 0 ||
|
||||||
|
!!String(lockState.payload.platform_amount_total || '').trim();
|
||||||
|
if (detail?.platform_amount && !hasPeriod) {
|
||||||
|
lockState.payload.platform_amount = detail.platform_amount;
|
||||||
|
}
|
||||||
|
if (detail?.status != null) {
|
||||||
|
applyBillStatus(Number(detail.status));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 详情失败不挡锁框
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDailyId(): Promise<number> {
|
||||||
|
const existId = Number(lockState.payload.daily_id || 0);
|
||||||
|
if (existId > 0) {
|
||||||
|
return existId;
|
||||||
|
}
|
||||||
|
const storeId = Number(lockState.payload.store_id || 0);
|
||||||
|
if (storeId < 1 || !billDateTxt.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const res = await generatePublicAccountDailyBill({
|
||||||
|
store_id: storeId,
|
||||||
|
bill_date: billDateTxt.value,
|
||||||
|
});
|
||||||
|
const createdId = Number(res?.id || 0);
|
||||||
|
if (createdId > 0) {
|
||||||
|
lockState.payload.daily_id = createdId;
|
||||||
|
}
|
||||||
|
return createdId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goPay() {
|
||||||
|
openingPay.value = true;
|
||||||
|
try {
|
||||||
|
const dailyId = await resolveDailyId();
|
||||||
|
if (dailyId < 1) {
|
||||||
|
message.warning('暂无待支付日账单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payDailyId.value = dailyId;
|
||||||
|
panelMode.value = 'pay';
|
||||||
|
} finally {
|
||||||
|
openingPay.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToHint() {
|
||||||
|
panelMode.value = 'hint';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有这次上传成功且未解锁,才切到等待审核 */
|
||||||
|
function onWaiting(payload?: {
|
||||||
|
status?: number;
|
||||||
|
bill_status?: number;
|
||||||
|
contact_phones?: { name?: string; phone: string }[];
|
||||||
|
}) {
|
||||||
|
if (payload?.contact_phones?.length) {
|
||||||
|
lockState.payload.contact_phones = payload.contact_phones;
|
||||||
|
}
|
||||||
|
applyBillStatus(3);
|
||||||
|
panelMode.value = 'wait';
|
||||||
|
}
|
||||||
|
|
||||||
|
function goWaitReupload() {
|
||||||
|
panelMode.value = 'pay';
|
||||||
|
}
|
||||||
|
|
||||||
|
function goWait() {
|
||||||
|
if (!canViewProgress.value) {
|
||||||
|
message.warning('暂无待审核进度');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payDailyId.value = Number(lockState.payload.daily_id || 0);
|
||||||
|
panelMode.value = 'wait';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击应付款:只读拉日账单 + 商品订单 */
|
||||||
|
function goOrders() {
|
||||||
|
panelMode.value = 'orders';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordersMergeId = computed(() => Number(lockState.payload.merge_id || 0));
|
||||||
|
const ordersDailyId = computed(() => Number(lockState.payload.daily_id || 0));
|
||||||
|
const ordersOverdueIds = computed(() => {
|
||||||
|
const raw = lockState.payload.overdue_daily_ids;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return [] as number[];
|
||||||
|
}
|
||||||
|
return raw.map((id) => Number(id)).filter((id) => id > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁框不可关闭,提供退出登录以免账号卡在锁店态
|
||||||
|
* 确认框抬高 zIndex,避免被锁层(2100)挡住
|
||||||
|
*/
|
||||||
|
function logout() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定退出登录?',
|
||||||
|
okText: '退出登录',
|
||||||
|
cancelText: '取消',
|
||||||
|
zIndex: PUBLIC_ACCOUNT_LOCK_PROMPT_Z_INDEX,
|
||||||
|
onOk: async () => {
|
||||||
|
if (loggingOut.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loggingOut.value = true;
|
||||||
|
try {
|
||||||
|
clearPublicAccountLock();
|
||||||
|
await authStore.logout(false);
|
||||||
|
} finally {
|
||||||
|
loggingOut.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
overlayOpen,
|
||||||
|
(open) => {
|
||||||
|
document.body.classList.toggle('pap-lock-open', !!open);
|
||||||
|
if (!open) {
|
||||||
|
panelMode.value = 'hint';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panelMode.value = 'hint';
|
||||||
|
void refreshLockStatus();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="overlayOpen" class="pap-lock-mask">
|
||||||
|
<div
|
||||||
|
class="pap-lock-panel"
|
||||||
|
:class="['is-' + alertColor, { 'is-sub': panelMode !== 'hint' }]"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<LockWarningCanvas
|
||||||
|
:active="overlayOpen"
|
||||||
|
:tapes-only="panelMode !== 'hint'"
|
||||||
|
:color="alertColor"
|
||||||
|
/>
|
||||||
|
<div class="pap-lock-shell">
|
||||||
|
<div class="pap-lock-scroll">
|
||||||
|
<LockPayPanel
|
||||||
|
v-if="panelMode === 'pay'"
|
||||||
|
:daily-id="payDailyId"
|
||||||
|
:platform-amount="amountTxt"
|
||||||
|
:bill-date-txt="billDateRangeTxt"
|
||||||
|
:unlock-mode="unlockMode"
|
||||||
|
@back="backToHint"
|
||||||
|
@waiting="onWaiting"
|
||||||
|
/>
|
||||||
|
<LockWaitPanel
|
||||||
|
v-else-if="panelMode === 'wait'"
|
||||||
|
:daily-id="payDailyId || Number(lockState.payload.daily_id || 0)"
|
||||||
|
:platform-amount="amountTxt"
|
||||||
|
:bill-date-txt="billDateRangeTxt"
|
||||||
|
:contacts="contactPhones"
|
||||||
|
:can-pay="canPay"
|
||||||
|
@reupload="goWaitReupload"
|
||||||
|
/>
|
||||||
|
<LockOrdersPanel
|
||||||
|
v-else-if="panelMode === 'orders'"
|
||||||
|
:merge-id="ordersMergeId"
|
||||||
|
:daily-id="ordersDailyId"
|
||||||
|
:overdue-daily-ids="ordersOverdueIds"
|
||||||
|
@back="backToHint"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<div class="pap-lock-badge">
|
||||||
|
{{ softHandle ? '待处理提醒' : '功能已锁定' }}
|
||||||
|
</div>
|
||||||
|
<p class="pap-lock-hello">{{ storeGreeting }}</p>
|
||||||
|
<h2 class="pap-lock-title">公账日账单已逾期</h2>
|
||||||
|
<p class="pap-lock-reason">{{ lockReasonTxt }}</p>
|
||||||
|
<p class="pap-lock-desc">
|
||||||
|
<template v-if="softHandle">
|
||||||
|
请尽快处理。完成后可返回继续工作。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
接诊、开方已暂停,本提示不可关闭。
|
||||||
|
</template>
|
||||||
|
<template v-if="mergeHint">
|
||||||
|
{{ mergeHint }}
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<div class="pap-lock-stat">
|
||||||
|
<div class="pap-lock-stat-item">
|
||||||
|
<span class="pap-lock-stat-label">{{ billDateLabel }}</span>
|
||||||
|
<strong>{{ billDateRangeTxt || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pap-lock-stat-item is-clickable"
|
||||||
|
@click="goOrders"
|
||||||
|
>
|
||||||
|
<span class="pap-lock-stat-label">合计应付款(点此查看明细)</span>
|
||||||
|
<strong class="is-amount">¥{{ amountTxt }}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pap-lock-flow">
|
||||||
|
<div class="pap-lock-flow-title">解锁流程</div>
|
||||||
|
<ol class="pap-lock-flow-list">
|
||||||
|
<li>
|
||||||
|
账单日{{ lockDaysLabel }} {{ lockTime }}
|
||||||
|
后未处理即锁定;更早的账单全天锁定。
|
||||||
|
</li>
|
||||||
|
<li>诊所管理员在本提示中上传付款凭证(转账截图)。</li>
|
||||||
|
<li v-if="unlockByVoucher">提交成功后立刻解锁,接诊和开方可继续。</li>
|
||||||
|
<li v-else>提交后仍锁定,等平台管理员确认到账后自动解锁。</li>
|
||||||
|
<li v-if="!softHandle">未达解锁条件前,无法关闭本提示,也无法继续业务。</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- 底部操作固定在可视区内,不随正文滚走 -->
|
||||||
|
<div class="pap-lock-foot">
|
||||||
|
<template v-if="panelMode === 'hint'">
|
||||||
|
<div v-if="canPay" class="pap-lock-actions">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="pap-lock-btn"
|
||||||
|
:loading="openingPay"
|
||||||
|
@click="goPay"
|
||||||
|
>
|
||||||
|
去上传凭证
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canViewProgress"
|
||||||
|
size="large"
|
||||||
|
class="pap-lock-btn is-ghost"
|
||||||
|
@click="goWait"
|
||||||
|
>
|
||||||
|
查看审核进度
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="pap-lock-hint">
|
||||||
|
请联系诊所管理员按上方流程上传付款凭证。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
v-if="softHandle"
|
||||||
|
type="button"
|
||||||
|
class="pap-lock-logout"
|
||||||
|
@click="closePublicAccountLockHandle"
|
||||||
|
>
|
||||||
|
返回工作台
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="pap-lock-logout"
|
||||||
|
:disabled="loggingOut"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 遮罩占满视口,子面板用 max-height:100% 才能真正不溢出 */
|
||||||
|
.pap-lock-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: max(10px, env(safe-area-inset-top, 0px))
|
||||||
|
max(10px, env(safe-area-inset-right, 0px))
|
||||||
|
max(10px, env(safe-area-inset-bottom, 0px))
|
||||||
|
max(10px, env(safe-area-inset-left, 0px));
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--background) / 62%);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.pap-lock-panel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(820px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
/* 相对遮罩内容盒,保证整框含警戒条都在视口内 */
|
||||||
|
max-height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
--pap-accent: var(--warning);
|
||||||
|
/* 顶留白跟警示三角对齐:矮屏大幅压缩 */
|
||||||
|
--pap-pad-top-hint: clamp(52px, 9dvh + 20px, 150px);
|
||||||
|
--pap-pad-x: clamp(14px, 3vw, 40px);
|
||||||
|
--pap-pad-y: clamp(8px, 1.6dvh, 20px);
|
||||||
|
--pap-gap-xs: clamp(2px, 0.5dvh, 6px);
|
||||||
|
--pap-gap-sm: clamp(6px, 1dvh, 12px);
|
||||||
|
--pap-gap-md: clamp(8px, 1.4dvh, 16px);
|
||||||
|
--pap-gap-lg: clamp(8px, 1.8dvh, 20px);
|
||||||
|
--pap-block-pad: clamp(8px, 1.2dvh, 14px);
|
||||||
|
--pap-fs-badge: clamp(11px, 1dvh + 5px, 12px);
|
||||||
|
--pap-fs-hello: clamp(12px, 1dvh + 5px, 15px);
|
||||||
|
--pap-fs-title: clamp(17px, 1.5dvh + 8px, 26px);
|
||||||
|
--pap-fs-reason: clamp(12px, 0.9dvh + 5px, 13px);
|
||||||
|
--pap-fs-desc: clamp(12px, 1dvh + 5px, 14px);
|
||||||
|
--pap-fs-label: clamp(11px, 0.8dvh + 5px, 12px);
|
||||||
|
--pap-fs-stat: clamp(13px, 1.2dvh + 5px, 18px);
|
||||||
|
--pap-fs-flow: clamp(11px, 0.9dvh + 4px, 12px);
|
||||||
|
--pap-fs-btn: clamp(14px, 1.1dvh + 5px, 16px);
|
||||||
|
--pap-btn-h: clamp(36px, 4.2dvh, 44px);
|
||||||
|
--pap-stat-cols: 1fr 1fr;
|
||||||
|
--pap-content-w: min(480px, 100%);
|
||||||
|
/* 给 canvas 上下警戒条留出露边,避免被底栏背景盖住 */
|
||||||
|
--pap-tape-h: 16px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 38%);
|
||||||
|
border-radius: clamp(10px, 1.2vw, 16px);
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-panel.is-blue {
|
||||||
|
--pap-accent: 217 91% 53%;
|
||||||
|
}
|
||||||
|
.pap-lock-panel.is-yellow {
|
||||||
|
--pap-accent: var(--warning);
|
||||||
|
}
|
||||||
|
.pap-lock-panel.is-red {
|
||||||
|
--pap-accent: 0 72% 51%;
|
||||||
|
}
|
||||||
|
.pap-lock-panel.is-sub {
|
||||||
|
--pap-pad-top-hint: clamp(14px, 2dvh, 28px);
|
||||||
|
}
|
||||||
|
/* shell:上滚下钉;上下内边距露出警戒条动画 */
|
||||||
|
.pap-lock-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
padding-top: var(--pap-tape-h);
|
||||||
|
padding-bottom: var(--pap-tape-h);
|
||||||
|
}
|
||||||
|
.pap-lock-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--pap-pad-top-hint) var(--pap-pad-x) var(--pap-gap-sm);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pap-lock-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--pap-gap-sm) var(--pap-pad-x) var(--pap-pad-y);
|
||||||
|
border-top: 1px solid hsl(var(--pap-accent) / 22%);
|
||||||
|
/* 半透明,滚动区边缘仍能透出粒子;底边警戒条靠 shell padding 露白 */
|
||||||
|
background: hsl(var(--card, var(--background)) / 88%);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.pap-lock-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 10px;
|
||||||
|
margin-bottom: var(--pap-gap-sm);
|
||||||
|
font-size: var(--pap-fs-badge);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 40%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--pap-accent) / 12%);
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-hello {
|
||||||
|
max-width: min(560px, 100%);
|
||||||
|
margin: 0 0 var(--pap-gap-xs);
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: var(--pap-fs-hello);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.pap-lock-title {
|
||||||
|
margin: 0 0 var(--pap-gap-xs);
|
||||||
|
font-size: var(--pap-fs-title);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-reason {
|
||||||
|
max-width: min(520px, 100%);
|
||||||
|
margin: 0 0 var(--pap-gap-xs);
|
||||||
|
font-size: var(--pap-fs-reason);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-desc {
|
||||||
|
max-width: min(520px, 100%);
|
||||||
|
margin: 0 0 var(--pap-gap-md);
|
||||||
|
font-size: var(--pap-fs-desc);
|
||||||
|
line-height: 1.45;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-stat {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--pap-stat-cols);
|
||||||
|
gap: var(--pap-gap-sm);
|
||||||
|
width: var(--pap-content-w);
|
||||||
|
margin-bottom: var(--pap-gap-md);
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--pap-block-pad) calc(var(--pap-block-pad) + 2px);
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 30%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item.is-clickable:hover {
|
||||||
|
border-color: hsl(var(--pap-accent) / 55%);
|
||||||
|
box-shadow: 0 0 10px hsl(var(--pap-accent) / 28%);
|
||||||
|
}
|
||||||
|
.pap-lock-stat-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--pap-gap-xs);
|
||||||
|
font-size: var(--pap-fs-label);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item strong {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--pap-fs-stat);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.pap-lock-stat-item strong.is-amount {
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
}
|
||||||
|
.pap-lock-flow {
|
||||||
|
width: var(--pap-content-w);
|
||||||
|
padding: var(--pap-block-pad) calc(var(--pap-block-pad) + 2px);
|
||||||
|
margin-bottom: 0;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid hsl(var(--pap-accent) / 30%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
|
||||||
|
}
|
||||||
|
.pap-lock-flow-title {
|
||||||
|
margin-bottom: var(--pap-gap-xs);
|
||||||
|
font-size: var(--pap-fs-flow);
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
}
|
||||||
|
.pap-lock-flow-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-size: var(--pap-fs-flow);
|
||||||
|
line-height: 1.45;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.pap-lock-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--pap-gap-sm);
|
||||||
|
width: var(--pap-content-w);
|
||||||
|
}
|
||||||
|
.pap-lock-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--pap-btn-h);
|
||||||
|
font-size: var(--pap-fs-btn);
|
||||||
|
}
|
||||||
|
.pap-lock-btn.is-ghost {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-color: hsl(var(--pap-accent) / 40%);
|
||||||
|
}
|
||||||
|
.pap-lock-hint {
|
||||||
|
max-width: var(--pap-content-w);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--pap-fs-desc);
|
||||||
|
line-height: 1.45;
|
||||||
|
color: hsl(var(--pap-accent));
|
||||||
|
}
|
||||||
|
.pap-lock-logout {
|
||||||
|
margin-top: var(--pap-gap-sm);
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--pap-fs-desc);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.pap-lock-logout:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
@media (max-height: 720px) {
|
||||||
|
.pap-lock-mask {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.pap-lock-panel {
|
||||||
|
/* 矮屏仍双列,省纵向高度;过窄才单列 */
|
||||||
|
--pap-pad-top-hint: clamp(44px, 7dvh + 16px, 96px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.pap-lock-mask {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.pap-lock-panel {
|
||||||
|
--pap-stat-cols: 1fr;
|
||||||
|
--pap-pad-x: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 锁层 z-index 保持 2100;antd message 默认 1010,锁屏后抬到 5000 才能看见提示 */
|
||||||
|
body.pap-lock-open .ant-message,
|
||||||
|
body.pap-lock-open .ant-notification {
|
||||||
|
z-index: 5000 !important;
|
||||||
|
}
|
||||||
|
/* 退出确认等 Modal 也要压过锁层 */
|
||||||
|
body.pap-lock-open .ant-modal-wrap,
|
||||||
|
body.pap-lock-open .ant-modal-mask {
|
||||||
|
z-index: 5000 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
apps/web-antd/src/components/stat-islands/index.ts
Normal file
5
apps/web-antd/src/components/stat-islands/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 通用统计岛组件导出:紧凑玻璃质感统计小卡(设计对齐提现管理账户卡统计岛)
|
||||||
|
*/
|
||||||
|
export { default as StatIslands } from './stat-islands.vue';
|
||||||
|
export type { StatIslandItem } from './typing';
|
||||||
181
apps/web-antd/src/components/stat-islands/stat-islands.vue
Normal file
181
apps/web-antd/src/components/stat-islands/stat-islands.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 通用统计岛组件:一行多个紧凑玻璃质感统计小卡
|
||||||
|
*
|
||||||
|
* 设计语言与提现管理账户卡的统计岛(finance/withdrawal AccountInfoCard)完全一致:
|
||||||
|
* 12px 圆角 + 1px 边框 + 主色渐变玻璃底 + 右上角 SVG 图标 + 角落径向荧光 +
|
||||||
|
* DIN 等宽大数字,hover 主色边框浮起。用于替换各页面过大的 AnalysisOverview 大卡。
|
||||||
|
*/
|
||||||
|
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import type { StatIslandItem } from './typing';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
items: StatIslandItem[];
|
||||||
|
/** 每行列数(桌面端),默认 4;窄屏自动降为 2 列 */
|
||||||
|
columns?: number;
|
||||||
|
/** 点击统计岛时调用(只处理 clickable 或全部可点项) */
|
||||||
|
onItemClick?: (item: StatIslandItem) => void;
|
||||||
|
}>(),
|
||||||
|
{ columns: 4 },
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleItemClick(item: StatIslandItem) {
|
||||||
|
if (!item.clickable || !props.onItemClick) return;
|
||||||
|
props.onItemClick(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 金额展示为两位小数;非数字原样透出(如「—」占位) */
|
||||||
|
function formatValue(val: number | string) {
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isFinite(n) ? n.toFixed(2) : String(val);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stat-islands" :style="{ '--stat-cols': columns }">
|
||||||
|
<article
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
class="stat-island"
|
||||||
|
:class="{
|
||||||
|
'stat-island--emphasize': item.emphasize,
|
||||||
|
'stat-island--clickable': item.clickable,
|
||||||
|
}"
|
||||||
|
:aria-label="item.label"
|
||||||
|
@click="handleItemClick(item)"
|
||||||
|
>
|
||||||
|
<div class="stat-island-glow" />
|
||||||
|
<VbenIcon class="stat-icon" :icon="item.icon" />
|
||||||
|
<div class="stat-label">{{ item.label }}</div>
|
||||||
|
<div class="stat-val">
|
||||||
|
<span v-if="item.prefix !== ''" class="stat-val-prefix">{{ item.prefix ?? '¥' }}</span>
|
||||||
|
{{ formatValue(item.value) }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stat-islands {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--stat-cols, 4), minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.stat-islands {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计岛:玻璃质感 + 右上角图标 + 淡荧光(与提现管理 stat-island 同规格) */
|
||||||
|
.stat-island {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 14px;
|
||||||
|
cursor: default;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(var(--primary) / 4%),
|
||||||
|
hsl(var(--card, var(--background)))
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 12px;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-island--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-island:hover {
|
||||||
|
border-color: hsl(var(--primary) / 40%);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px hsl(var(--primary) / 14%),
|
||||||
|
0 2px 6px rgb(0 0 0 / 6%);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 角落局部高光,强化荧光感 */
|
||||||
|
.stat-island-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
right: -40px;
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-island:hover .stat-island-glow {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 强调项:主色字 + 更强荧光 */
|
||||||
|
.stat-island--emphasize {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(var(--primary) / 10%),
|
||||||
|
hsl(var(--card, var(--background)))
|
||||||
|
);
|
||||||
|
border-color: hsl(var(--primary) / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-island--emphasize .stat-island-glow {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-island--emphasize .stat-val {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右上角图标:固定 24x24,主色调 */
|
||||||
|
.stat-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: hsl(var(--primary) / 70%);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-island:hover .stat-icon {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding-right: 32px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-val {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-val-prefix {
|
||||||
|
margin-right: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
apps/web-antd/src/components/stat-islands/typing.ts
Normal file
21
apps/web-antd/src/components/stat-islands/typing.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 统计岛组件类型定义(独立文件避免 index ↔ 组件循环引用)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 统计岛单项配置 */
|
||||||
|
export interface StatIslandItem {
|
||||||
|
/** 唯一 key */
|
||||||
|
key: string;
|
||||||
|
/** 指标名(小灰字) */
|
||||||
|
label: string;
|
||||||
|
/** 指标值(数字,内部格式化两位小数) */
|
||||||
|
value: number | string;
|
||||||
|
/** iconify 图标名(右上角,规范:SVG 图标不用 emoji) */
|
||||||
|
icon: string;
|
||||||
|
/** 是否强调(主色字 + 更强荧光) */
|
||||||
|
emphasize?: boolean;
|
||||||
|
/** 值前缀,默认 ¥;传空串则不显示前缀 */
|
||||||
|
prefix?: string;
|
||||||
|
/** 是否可点击下钻;为 true 时用 pointer 并触发 onItemClick */
|
||||||
|
clickable?: boolean;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user