Compare commits

..

330 Commits

Author SHA1 Message Date
van
d0883e8f89 1 2026-04-11 22:37:14 +08:00
van
cd5c49762a 1 2026-04-11 22:35:42 +08:00
van
124af53209 1 2026-04-10 20:54:09 +08:00
van
beb784d895 1 2026-04-10 18:57:33 +08:00
van
92ed8c757a 1 2026-04-10 16:59:17 +08:00
van
a9f4c5adab 1 2026-04-10 16:24:52 +08:00
van
be9d4e182e 1 2026-04-10 16:20:28 +08:00
van
c2af969354 1 2026-04-10 16:20:13 +08:00
van
97514c8ce2 1 2026-04-10 16:10:30 +08:00
van
1e5d10964a 1 2026-04-10 01:09:38 +08:00
van
c87f4a1206 1 2026-04-10 01:00:13 +08:00
van
5e7b213497 1 2026-04-10 00:29:10 +08:00
van
416a7d77f5 1 2026-04-09 12:52:32 +08:00
van
ffe48e0417 1 2026-04-08 17:31:13 +08:00
van
eefbc2ba87 1 2026-04-08 16:36:23 +08:00
van
fc21a504eb 1 2026-04-08 16:26:18 +08:00
van
0ac6a25cd0 1 2026-04-07 21:35:39 +08:00
van
e68b1b25c8 1 2026-04-07 18:45:07 +08:00
van
e71867c4ee 1 2026-04-07 18:39:00 +08:00
van
4c627e7f26 1 2026-04-07 17:40:44 +08:00
van
8addb6080c 1 2026-04-07 17:29:30 +08:00
van
a2263eeef9 1 2026-04-07 15:22:17 +08:00
van
f685a5e7e4 1 2026-04-07 11:05:50 +08:00
van
3d2bf9902d 1 2026-04-07 02:28:37 +08:00
van
0d261efd49 1 2026-04-07 02:23:46 +08:00
van
1ad851fb6f 1 2026-04-07 00:52:41 +08:00
van
d1e053c677 1 2026-04-06 23:42:45 +08:00
van
42ab4079eb 1 2026-04-06 17:49:23 +08:00
van
f7d078f885 1 2026-04-06 17:40:09 +08:00
van
2a53f8bbff 1 2026-04-06 16:16:47 +08:00
van
733f9624cc 1 2026-04-06 11:37:02 +08:00
van
679e0dd3af 1 2026-04-06 11:15:08 +08:00
van
db7687e7df 1 2026-04-05 23:13:50 +08:00
van
f845044a27 1 2026-04-05 21:37:47 +08:00
van
2d1f980a47 1 2026-04-05 21:35:50 +08:00
van
5cbcfa9533 1 2026-04-05 21:34:48 +08:00
van
5fcf92d2da 1 2026-04-05 20:47:03 +08:00
van
305ef3eeee 1 2026-04-04 17:20:31 +08:00
van
188c590178 1 2026-04-04 16:39:49 +08:00
van
13df289d33 1 2026-04-04 16:20:50 +08:00
van
07b49d10c7 1 2026-04-03 17:48:31 +08:00
van
b79acb6471 1 2026-04-03 00:58:24 +08:00
van
0cb49410a3 1 2026-04-03 00:42:56 +08:00
van
e3abff037b 1 2026-04-03 00:34:10 +08:00
van
4a59e428a6 1 2026-04-01 17:12:49 +08:00
van
5ad9007fea 1 2026-04-01 15:52:59 +08:00
van
58e48e3ebf 1 2026-03-24 16:24:00 +08:00
van
0810a2b46a 1 2026-03-24 15:36:57 +08:00
van
2f296b5fb5 1 2026-03-24 01:09:04 +08:00
van
1b41508013 1 2026-03-23 23:56:45 +08:00
van
e63ab42c41 1 2026-03-23 17:04:47 +08:00
van
1ddf792076 1 2026-03-22 20:42:15 +08:00
van
da906d52c0 1 2026-03-11 22:17:12 +08:00
van
bc33717921 1 2026-03-11 21:56:24 +08:00
van
2b9e1aef3f 1 2026-03-11 21:40:23 +08:00
van
60c921ea28 1 2026-03-11 21:26:01 +08:00
van
9f18f13607 1 2026-03-10 00:31:57 +08:00
van
27c70ac567 1 2026-03-09 16:57:55 +08:00
van
4fba24438c 1 2026-03-09 15:25:19 +08:00
van
bf092e5f69 1 2026-03-03 19:21:39 +08:00
van
d680da2d83 1 2026-03-01 00:07:16 +08:00
Leo
85ec563242 1 2026-02-09 11:57:14 +08:00
Leo
9794bec2ae 1 2026-02-05 12:11:28 +08:00
Leo
7a39dd32cf 1 2026-02-05 12:09:24 +08:00
Leo
b01c71fdb1 1 2026-02-04 23:30:32 +08:00
Leo
829aa0cd4e 1 2026-02-04 18:44:38 +08:00
Leo
9d089497ce 1 2026-02-04 18:41:00 +08:00
Leo
4a8618f8e5 1 2026-02-04 18:37:03 +08:00
Leo
e334b0fb51 1 2026-02-04 18:29:57 +08:00
Leo
edfe0ca0a1 1 2026-02-04 18:27:45 +08:00
Leo
444c7954fd 1 2026-02-04 18:21:53 +08:00
Leo
9fb2d6a914 1 2026-02-04 16:34:39 +08:00
Leo
6f3b0a2467 1 2026-02-03 15:27:02 +08:00
Leo
df0bcf9d27 11 2026-02-03 15:19:31 +08:00
Leo
4c2803d911 1 2026-02-03 15:14:24 +08:00
Leo
41078c63e6 1 2026-02-02 16:45:05 +08:00
Leo
b4d18c6cfb 1 2026-02-02 16:42:10 +08:00
Leo
6af927d8be 1 2026-02-02 16:36:13 +08:00
Leo
9cea8b24b8 1 2026-02-02 16:27:13 +08:00
Leo
6ff8f18604 1 2026-01-30 19:43:15 +08:00
Leo
53edda8a02 1 2026-01-26 22:31:42 +08:00
Leo
5118598d83 1 2026-01-19 14:11:26 +08:00
Leo
d8e71d9bf2 1 2026-01-17 22:34:32 +08:00
Leo
300293b68b 已增大型号选择按钮的尺寸: 2026-01-17 21:46:23 +08:00
Leo
ada7aaf1f5 1 2026-01-17 19:27:49 +08:00
Leo
fc237c9bfd 1 2026-01-17 18:51:04 +08:00
Leo
ad58ef9c33 修复遮罩未自动释放的问题 2026-01-17 18:43:58 +08:00
Leo
ff3537ca35 1 2026-01-17 18:40:33 +08:00
Leo
f194311d2a 1 2026-01-17 18:28:38 +08:00
Leo
1639a650cf 1 2026-01-17 18:22:15 +08:00
Leo
dc66a9cf53 Autowired 2026-01-16 21:13:00 +08:00
Leo
1a585d8469 Autowired 2026-01-16 21:08:01 +08:00
Leo
4b3d14f699 1 2026-01-16 20:49:07 +08:00
Leo
c3cc665948 1 2026-01-16 20:45:52 +08:00
Leo
37b30f4ddb 1 2026-01-16 20:10:05 +08:00
Leo
0053741c05 1 2026-01-16 20:09:49 +08:00
Leo
2ed4625bfd 字母按钮 2026-01-16 18:55:22 +08:00
Leo
f2867bfed4 1 2026-01-16 18:49:42 +08:00
Leo
2cc538120f 1 2026-01-16 18:07:27 +08:00
Leo
f03e82acb5 1 2026-01-16 18:03:00 +08:00
Leo
c7bad0e5e5 1 2026-01-15 21:50:57 +08:00
Leo
3cec899df2 1 2026-01-15 21:13:56 +08:00
Leo
1f4a6b394f 1 2026-01-15 21:07:56 +08:00
Leo
04dd5396ac 1 2026-01-15 19:50:23 +08:00
Leo
09cb3c2862 1 2026-01-15 16:32:00 +08:00
Leo
27f40074e3 1 2026-01-15 16:21:57 +08:00
Leo
5ff08414bc 1 2026-01-15 16:02:07 +08:00
Leo
c3d13db31b 1 2026-01-14 22:55:39 +08:00
Leo
b215f34aa8 1 2026-01-14 12:29:35 +08:00
Leo
dc01036abf 1 2026-01-13 23:00:41 +08:00
Leo
cc09f016d2 1 2026-01-13 22:50:53 +08:00
Leo
73b7bad859 1 2026-01-07 15:27:48 +08:00
Leo
979a7021b1 1 2026-01-07 15:24:10 +08:00
Leo
f6d681a698 1 2026-01-07 15:15:09 +08:00
Leo
9dc148400c 1 2026-01-06 23:00:41 +08:00
Leo
3779523047 1 2026-01-06 22:52:28 +08:00
Leo
acfc5e60f4 1 2026-01-06 18:28:43 +08:00
Leo
7871acf214 11 2026-01-06 01:38:33 +08:00
Leo
70ce063aba 1 2026-01-06 01:20:13 +08:00
Leo
f79535622a 1 2026-01-06 01:08:28 +08:00
Leo
b38633ff49 1 2026-01-06 00:20:56 +08:00
Leo
8d2433e432 1 2026-01-05 23:15:23 +08:00
Leo
0d03604888 1 2026-01-05 23:04:49 +08:00
Leo
986cdd6fd9 1 2026-01-05 22:39:32 +08:00
Leo
af0000107f 1 2026-01-05 22:02:21 +08:00
Leo
5367eb7834 1 2026-01-05 19:37:20 +08:00
Leo
9b2473334b 1 2026-01-05 19:16:39 +08:00
Leo
5dc38831eb 11 2026-01-05 18:59:53 +08:00
Leo
a3291f7a31 1 2026-01-05 18:32:29 +08:00
Leo
1a4e56bfed 1 2026-01-03 12:13:23 +08:00
Leo
5d037eaeee 1 2026-01-03 12:05:10 +08:00
Leo
584a55094e 1 2026-01-03 12:03:12 +08:00
Leo
fa45ace9a4 1 2026-01-03 11:43:54 +08:00
Leo
742bb9d063 1 2025-12-22 22:56:22 +08:00
Leo
ebb3497992 1 2025-12-14 00:01:09 +08:00
Leo
ae21b33b87 1 2025-12-08 14:57:05 +08:00
Leo
429b62a561 1 2025-12-08 14:42:47 +08:00
Leo
4417085d75 1 2025-12-08 13:04:09 +08:00
Leo
75329ffb84 1 2025-12-05 22:36:03 +08:00
Leo
13ae226379 1 2025-12-02 17:46:20 +08:00
Leo
1853fb55ac 1 2025-12-02 17:39:29 +08:00
Leo
a12a17df21 1 2025-12-02 01:45:22 +08:00
Leo
86e8fefb97 1 2025-11-29 23:39:46 +08:00
Leo
1234ad42a4 1 2025-11-29 22:56:40 +08:00
Leo
ec921d313c 1 2025-11-29 22:47:44 +08:00
Leo
a21f6f77a3 1 2025-11-29 22:35:02 +08:00
Leo
485e306082 1 2025-11-26 17:48:24 +08:00
Leo
061029fb0c 1 2025-11-22 13:34:18 +08:00
Leo
d3a9f5039a 1 2025-11-21 23:26:33 +08:00
Leo
bea1e46deb 1 2025-11-21 20:57:46 +08:00
Leo
6ef5d644a1 1 2025-11-20 23:38:06 +08:00
Leo
73ce628a43 1 2025-11-16 14:29:21 +08:00
Leo
00a02866e2 1 2025-11-15 23:59:39 +08:00
Leo
1aa6d4ad3a 1 2025-11-15 23:45:43 +08:00
Leo
38f4664272 1 2025-11-15 18:09:46 +08:00
Leo
d25f41d147 1 2025-11-15 17:56:03 +08:00
Leo
57d6095555 1 2025-11-15 17:37:33 +08:00
Leo
787dc33256 1 2025-11-15 17:28:35 +08:00
Leo
a04ba55b7e 1 2025-11-15 16:33:58 +08:00
Leo
91e48855f4 1 2025-11-15 15:34:13 +08:00
Leo
3a40d5f872 1 2025-11-15 15:15:13 +08:00
Leo
0b4d241012 1 2025-11-15 15:08:04 +08:00
Leo
77685eca9d 1 2025-11-15 13:57:04 +08:00
Leo
a0b672c969 1 2025-11-15 11:26:04 +08:00
Leo
2d342a8ee0 1 2025-11-15 01:45:23 +08:00
Leo
74c42ac250 1 2025-11-15 01:31:46 +08:00
Leo
a2a9f01e2c 1 2025-11-15 01:02:52 +08:00
Leo
c3f342dfba 1 2025-11-15 00:54:33 +08:00
Leo
cc215aec29 1 2025-11-15 00:46:02 +08:00
Leo
b71e946bd0 1 2025-11-14 23:56:02 +08:00
Leo
1cd54adb06 1 2025-11-14 23:48:23 +08:00
Leo
f67002ecfb 1 2025-11-14 23:38:50 +08:00
Leo
c595b4df0a 1 2025-11-14 00:13:21 +08:00
Leo
101b3dae54 1 2025-11-14 00:02:45 +08:00
Leo
47951ab5ea 1 2025-11-13 23:55:31 +08:00
Leo
a07452ea8b 1 2025-11-13 23:51:47 +08:00
Leo
752b3ff1ca 1 2025-11-13 23:43:29 +08:00
Leo
e99ce93bc1 1 2025-11-13 23:38:32 +08:00
Leo
c858ab5ac7 1 2025-11-13 16:08:47 +08:00
Leo
9c8048ce7b 1 2025-11-11 14:13:13 +08:00
Leo
5bc1fcd83d 1 2025-11-11 00:39:33 +08:00
Leo
c77e95802c 1 2025-11-11 00:36:38 +08:00
Leo
aa8050543e 1 2025-11-10 21:15:30 +08:00
Leo
4bc6cbfcc5 1 2025-11-10 21:13:28 +08:00
Leo
c9defd4a67 1 2025-11-10 19:02:55 +08:00
Leo
e819383722 1 2025-11-10 18:55:07 +08:00
Leo
85b3972aa4 1 2025-11-09 17:52:38 +08:00
Leo
0d92865041 1 2025-11-09 00:46:13 +08:00
Leo
13ec358145 1 2025-11-08 15:25:51 +08:00
42077dbfd1 1 2025-11-07 21:15:21 +08:00
083fbba4e8 1 2025-11-07 21:09:14 +08:00
b79d074705 1 2025-11-07 18:16:05 +08:00
5b607c8031 1 2025-11-07 16:10:23 +08:00
4f6403d08c 1 2025-11-07 15:56:43 +08:00
0c4937816f 1 2025-11-07 15:52:34 +08:00
d02c9ac4cf 1 2025-11-07 15:35:26 +08:00
80def4201c 1 2025-11-07 15:23:04 +08:00
9672e191e1 1 2025-11-07 14:40:47 +08:00
a411e42094 1 2025-11-07 13:30:24 +08:00
0dde8db6fd 1 2025-11-07 01:35:43 +08:00
92d29fe73f 1 2025-11-07 01:29:55 +08:00
059b5e05fb 1 2025-11-07 01:29:07 +08:00
a284047b48 1 2025-11-07 01:23:45 +08:00
a897cdcae9 1 2025-11-06 21:38:47 +08:00
ee831e5931 1 2025-11-06 17:53:19 +08:00
264bd81307 1 2025-11-06 17:39:23 +08:00
79b32c887d 1 2025-11-06 17:23:45 +08:00
ab9ec7e530 1 2025-11-06 16:45:16 +08:00
d4d4cc614b 1 2025-11-06 16:34:26 +08:00
41ed4f3f34 1 2025-11-06 16:18:51 +08:00
96cbb5d78f 1 2025-11-06 16:08:25 +08:00
a989a000fb 1 2025-11-06 16:04:29 +08:00
d852f03e62 1 2025-11-06 15:25:14 +08:00
9ed12b9248 1 2025-11-06 00:13:06 +08:00
c6fa3d0018 1 2025-11-05 23:33:04 +08:00
e9e5b7ee52 1 2025-11-05 23:13:09 +08:00
4959b2f34f 1 2025-11-05 22:54:01 +08:00
364276d85b 1 2025-11-05 22:47:11 +08:00
df9085baa4 1 2025-11-05 20:15:59 +08:00
3ea26320cc 1 2025-11-05 19:38:06 +08:00
283cfbbfc8 1 2025-11-05 15:42:51 +08:00
855d22f448 1 2025-11-05 13:27:52 +08:00
2fab612906 1 2025-11-05 12:54:35 +08:00
d7a71931a9 1 2025-11-04 23:03:38 +08:00
1a9edf7e1b 1 2025-11-03 22:11:26 +08:00
6065f3f865 1 2025-11-03 21:16:51 +08:00
1cbab3f248 1 2025-11-03 20:02:57 +08:00
a68eba7b5f 1 2025-11-03 19:44:15 +08:00
e5c7af48a2 1 2025-11-03 10:53:05 +08:00
a16d127512 1 2025-11-03 10:51:10 +08:00
75f75cb875 1 2025-11-03 10:43:48 +08:00
78a74a9787 1 2025-11-02 17:58:53 +08:00
ecf8285856 1 2025-11-01 14:28:44 +08:00
fa2e00f9bc 1 2025-10-31 22:25:12 +08:00
93bf30338a 1 2025-10-31 22:20:06 +08:00
2095fc78e6 1 2025-10-31 16:58:20 +08:00
8b5aa28b8f 1 2025-10-31 16:54:48 +08:00
cb1cea512a 1 2025-10-31 16:50:25 +08:00
e62a2b3635 1 2025-10-31 16:48:49 +08:00
94cb24041a 2 2025-10-31 16:45:34 +08:00
83ffdc1f2d 1 2025-10-31 16:07:55 +08:00
5a32bdf544 1 2025-10-31 15:56:38 +08:00
06edf3d165 1 2025-10-31 15:47:31 +08:00
60214c1acb 1 2025-10-31 15:45:46 +08:00
7026d1fe1d 1 2025-10-31 15:30:59 +08:00
40d66ae230 1 2025-10-31 13:18:51 +08:00
10a6ee9e3a 1 2025-10-31 12:55:21 +08:00
5e44b8ccd8 1 2025-10-31 00:32:27 +08:00
2c2c451c96 1 2025-10-31 00:30:43 +08:00
136a64d8cb 1 2025-10-30 18:24:09 +08:00
1acc3f7a9a 1 2025-10-30 16:52:22 +08:00
edec1cbc08 1 2025-10-30 02:27:38 +08:00
fd8400afe4 1 2025-10-30 01:17:00 +08:00
a5b15e2069 1 2025-10-30 01:03:43 +08:00
aae92dc9d0 1 2025-10-29 23:43:39 +08:00
98ae13db7a 1 2025-10-29 19:43:03 +08:00
9387722860 1 2025-10-29 18:56:20 +08:00
153d599373 1 2025-10-28 17:50:45 +08:00
2096302eca 1 2025-10-27 23:59:31 +08:00
8b6dd7d8a8 1 2025-10-27 22:54:20 +08:00
2c46da50e3 1 2025-10-24 00:05:04 +08:00
8c86983ace 1 2025-10-23 23:28:19 +08:00
399b31e8e0 1 2025-10-22 15:22:08 +08:00
27f92fb3dd 1 2025-10-21 23:37:03 +08:00
8f6f1c9557 Merge branch 'master' of https://git.van333.cn/CC/ruoyi-vue 2025-10-21 23:27:24 +08:00
12b6a51bcd 1 2025-10-21 23:27:21 +08:00
雷欧(林平凡)
f28f0ad4ab 1 2025-10-16 11:38:40 +08:00
雷欧(林平凡)
9cc0ee358c 1 2025-10-13 17:53:56 +08:00
雷欧(林平凡)
51f77af121 1 2025-10-11 18:19:45 +08:00
雷欧(林平凡)
114ae26c8f 1 2025-10-11 16:11:52 +08:00
f302c9ea69 1 2025-10-10 02:25:27 +08:00
雷欧(林平凡)
13cf9865dd Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.env.development
#	.env.production
2025-10-09 11:36:37 +08:00
雷欧(林平凡)
73e132a648 1 2025-10-09 11:31:05 +08:00
雷欧(林平凡)
9f70082ed6 1 2025-10-09 11:22:07 +08:00
d41cf24764 1 2025-10-09 00:40:50 +08:00
f36dc4f3d3 1 2025-10-05 03:09:15 +08:00
57f1a7f121 1 2025-10-01 20:09:59 +08:00
950744adca 1 2025-10-01 17:12:12 +08:00
雷欧(林平凡)
8e32bd2463 1 2025-09-19 19:09:09 +08:00
雷欧(林平凡)
a14b64c14b 1 2025-09-19 19:05:57 +08:00
雷欧(林平凡)
6d6d460dfc 1 2025-09-19 19:01:35 +08:00
雷欧(林平凡)
d68681faa0 1 2025-09-19 18:35:07 +08:00
雷欧(林平凡)
391509b766 1 2025-09-19 18:04:46 +08:00
雷欧(林平凡)
f33e6be27d 1 2025-09-19 17:39:56 +08:00
雷欧(林平凡)
983b773a9c 1 2025-09-19 17:14:04 +08:00
雷欧(林平凡)
e060b235dc 1 2025-09-19 17:03:08 +08:00
雷欧(林平凡)
29df75d58d 1 2025-09-19 16:57:52 +08:00
雷欧(林平凡)
fab8df2f1c 1 2025-09-19 16:38:41 +08:00
雷欧(林平凡)
0c1201baee 1 2025-09-18 17:19:58 +08:00
雷欧(林平凡)
645eac253c 1 2025-09-18 17:05:36 +08:00
雷欧(林平凡)
63d523566d 1 2025-09-18 17:05:33 +08:00
207c68c121 1 2025-09-13 22:58:19 +08:00
91e0764b09 1 2025-09-13 22:56:47 +08:00
edb3dce4ef 1 2025-09-13 22:55:10 +08:00
653f963a57 1 2025-09-13 22:53:59 +08:00
b514c9004f 1 2025-09-13 22:48:04 +08:00
956c83afe2 1 2025-09-13 20:16:40 +08:00
雷欧(林平凡)
c873558369 1 2025-09-12 13:52:35 +08:00
雷欧(林平凡)
3d258dc6aa 1 2025-09-09 11:19:54 +08:00
雷欧(林平凡)
4a30efaa56 1 2025-09-08 15:49:15 +08:00
雷欧(林平凡)
97514d74cb Merge branch 'master' of https://git.van333.cn/CC/ruoyi-vue 2025-09-08 15:46:14 +08:00
雷欧(林平凡)
2a87cfbf11 1 2025-09-08 15:45:27 +08:00
24105e0972 1 2025-09-07 17:50:37 +08:00
dea68663ef 1 2025-09-07 17:35:42 +08:00
2fb0f26b6d 1 2025-09-06 18:07:26 +08:00
7e50e55a30 1 2025-09-06 16:23:48 +08:00
0255e5d99d 1 2025-09-05 18:56:28 +08:00
0a45d62278 1 2025-08-31 15:29:05 +08:00
雷欧(林平凡)
acb59b64bd 1 2025-08-28 13:50:35 +08:00
雷欧(林平凡)
ef1a0430d4 1 2025-08-28 10:17:48 +08:00
6d257dc840 1 2025-08-28 01:19:56 +08:00
483a7c019c 1 2025-08-27 21:15:24 +08:00
4a7d948061 1 2025-08-27 21:02:13 +08:00
8a1effedfc 1 2025-08-27 20:36:31 +08:00
3effbc342c 1 2025-08-25 01:46:41 +08:00
2d4f31a116 1 2025-08-24 19:17:09 +08:00
雷欧(林平凡)
4b4d0cb755 1 2025-08-24 16:49:43 +08:00
eb1c66b3b6 1 2025-08-21 23:42:49 +08:00
雷欧(林平凡)
c8fdaf9e35 1 2025-08-21 19:14:55 +08:00
雷欧(林平凡)
ac48ccfc26 1 2025-08-21 19:02:09 +08:00
雷欧(林平凡)
03f69d8361 1 2025-08-21 16:01:40 +08:00
fd53ce5c98 1 2025-08-20 23:19:51 +08:00
fe9eff8131 Merge branch 'master' of https://git.van333.cn/CC/ruoyi-vue 2025-08-20 23:12:44 +08:00
459539349f 1 2025-08-20 23:12:42 +08:00
雷欧(林平凡)
af966956a2 1 2025-08-20 18:00:56 +08:00
3a441bddc2 1 2025-08-20 01:01:01 +08:00
d09f988d7c Merge branch 'master' of https://git.van333.cn/CC/ruoyi-vue 2025-08-19 01:45:46 +08:00
07c1a23a89 1 2025-08-19 01:28:04 +08:00
雷欧(林平凡)
b78d53df5d 1 2025-08-18 17:55:40 +08:00
fd220a4038 1 2025-08-18 01:59:58 +08:00
115 changed files with 35175 additions and 687 deletions

View File

@@ -1,14 +1,16 @@
# 页面标题 # 页面标题
VUE_APP_TITLE = Jarvis VUE_APP_TITLE=Jarvis
# 开发环境配置 # 开发环境配置
ENV = 'development' ENV=development
# Jarvis/开发环境
VUE_APP_BASE_API = ''
# 路由懒加载 # 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true VUE_CLI_BABEL_TRANSPILE_MODULES=true
VUE_APP_BASE_API = 'http://134.175.126.60:30313' # 前端请求前缀(与 axios baseURL 一致);由 vue.config.js devServer 代理到下方后端
port = 8888 VUE_APP_BASE_API=/jarvis-api
# 仅 npm run dev 时生效webpack 将 /jarvis-api 代理到该地址(可改为本机或其它局域网 IP
VUE_APP_DEV_PROXY_TARGET=http://192.168.8.88:30313
port=8888

View File

@@ -1,11 +1,10 @@
# 页面标题 # 页面标题
VUE_APP_TITLE = Jarvis VUE_APP_TITLE=Jarvis
# 生产环境配置 # 生产环境配置
ENV = 'production' ENV=production
# Jarvis/生产环境 # 打包后接口前缀:浏览器请求「当前站点域名 + 此前缀」,由部署机 Nginx 反代到公网/内网后端(勿写死 IP
VUE_APP_BASE_API = '' VUE_APP_BASE_API=/jarvis-api
VUE_APP_BASE_API = 'http://134.175.126.60:30313' port=8888
port = 8888

156
HTTPS部署说明.md Normal file
View File

@@ -0,0 +1,156 @@
# HTTPS部署配置说明
## 问题说明
在HTTPS访问情况下**不能直接使用 `http://127.0.0.1:30313` 请求后端接口**,因为会出现**混合内容Mixed Content**问题:
- 浏览器会阻止从HTTPS页面请求HTTP资源
- 控制台会报错:`Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure resource 'http://...'`
## 解决方案
### 方案一通过Nginx代理推荐
#### 1. 修改前端环境变量配置
在生产环境打包时,需要创建 `.env.production` 文件设置API路径为相对路径
```bash
# .env.production
VUE_APP_BASE_API=/dev-api
```
这样前端打包后所有API请求都会使用 `/dev-api` 作为前缀,例如:
- 原请求:`http://127.0.0.1:30313/system/user/list`
- 打包后:`/dev-api/system/user/list`
- 实际请求:`https://jarvis.van333.cn/dev-api/system/user/list`
#### 2. 使用提供的nginx配置
已创建 `nginx-https.conf` 配置文件,包含以下关键配置:
```nginx
# API代理配置
location /dev-api/ {
proxy_pass http://127.0.0.1:30313/; # 后端服务地址
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
**工作原理:**
- 用户访问:`https://jarvis.van333.cn/dev-api/system/user/list`
- Nginx代理转发到`http://127.0.0.1:30313/system/user/list`
- 后端处理请求,返回结果
- Nginx将结果返回给用户通过HTTPS
#### 3. 部署步骤
1. **创建生产环境配置文件**(如果不存在):
```bash
# 在项目根目录创建 .env.production
echo "VUE_APP_BASE_API=/dev-api" > .env.production
```
2. **打包前端项目**
```bash
npm run build:prod
```
3. **部署到服务器**
```bash
# 将 dist 目录内容复制到 /www/sites/jarvis.van333.cn/index/
```
4. **更新nginx配置**
```bash
# 将 nginx-https.conf 内容替换到你的nginx配置
# 或直接使用sudo cp nginx-https.conf /etc/nginx/sites-available/jarvis.van333.cn
```
5. **重启nginx**
```bash
sudo nginx -t # 测试配置
sudo nginx -s reload # 重新加载配置
```
### 方案二后端也配置HTTPS不推荐
如果后端也配置HTTPS可以直接使用 `https://127.0.0.1:30313`,但这样会增加配置复杂度。
## 注意事项
### 1. API路径匹配
确保nginx的 `location` 路径与前端 `VUE_APP_BASE_API` 配置一致:
- 如果 `VUE_APP_BASE_API=/dev-api`则nginx配置 `location /dev-api/`
- 如果 `VUE_APP_BASE_API=/api`则nginx配置 `location /api/`
### 2. 路径重写
当前配置中nginx会将 `/dev-api/xxx` 转发到 `http://127.0.0.1:30313/xxx`(去掉 `/dev-api` 前缀)。
如果后端需要保留前缀,可以修改为:
```nginx
location /dev-api/ {
proxy_pass http://127.0.0.1:30313/dev-api/; # 保留前缀
# ... 其他配置
}
```
### 3. WebSocket支持
如果后端使用了WebSocketnginx配置中已包含相关设置
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
### 4. 超时设置
已配置600秒超时适合长时间运行的接口。可根据实际需求调整。
## 验证配置
部署后,可以通过以下方式验证:
1. **浏览器开发者工具**
- 打开 `https://jarvis.van333.cn`
- F12 打开开发者工具
- Network 标签查看API请求
- 确认请求URL为 `https://jarvis.van333.cn/dev-api/...`
- 确认没有混合内容错误
2. **测试API请求**
```bash
curl -k https://jarvis.van333.cn/dev-api/system/user/list
```
3. **检查nginx日志**
```bash
tail -f /www/sites/jarvis.van333.cn/log/access.log
tail -f /www/sites/jarvis.van333.cn/log/error.log
```
## 常见问题
### Q: 为什么前端请求还是显示 `http://127.0.0.1:30313`
A: 检查是否创建了 `.env.production` 文件,并且重新打包了项目。
### Q: 出现 502 Bad Gateway 错误?
A:
1. 检查后端服务是否运行在 `127.0.0.1:30313`
2. 检查nginx配置中的 `proxy_pass` 地址是否正确
3. 检查防火墙是否允许nginx访问后端端口
### Q: 出现 404 Not Found 错误?
A:
1. 检查nginx的 `location /dev-api/` 配置是否正确
2. 检查后端接口路径是否正确
3. 查看nginx错误日志`tail -f /www/sites/jarvis.van333.cn/log/error.log`

View File

@@ -0,0 +1,301 @@
# 操作日志查看功能 - 快速上手指南
## 🚀 快速部署
### 1⃣ 重新编译前端
```bash
cd d:\code\ruoyi-vue
npm run build:prod
```
或者开发模式:
```bash
npm run dev
```
### 2⃣ 重新编译后端(如果还没编译)
```bash
cd d:\code\RuoYi-Vue-master\ruoyi-java
mvn clean package -DskipTests
```
### 3⃣ 重启服务
重启前端和后端服务。
### 4⃣ 清除浏览器缓存
`Ctrl + F5` 强制刷新页面。
---
## 📍 如何打开日志页面
### 方法:从配置对话框打开
1. 打开**订单列表**页面
2. 点击顶部的 **"H-TF自动写入配置"** 按钮(绿色)
3. 在弹出的配置对话框底部,找到 **"查看操作日志"** 按钮(蓝色,带文档图标)
4. 点击后即可查看操作日志
---
## 📊 功能演示
### 界面布局
```
┌─────────────────────────────────────────────────────────────────┐
│ 腾讯文档操作日志 [X] │
├─────────────────────────────────────────────────────────────────┤
│ 搜索框: [订单号] [操作类型▼] [操作状态▼] [搜索] [重置] │
├─────────────────────────────────────────────────────────────────┤
│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ 成功 │ │ 跳过 │ │ 失败 │ │ 总计 │ │
│ │ 150 │ │ 500 │ │ 10 │ │ 660 │ │
│ └───────┘ └───────┘ └───────┘ └───────┘ │
├─────────────────────────────────────────────────────────────────┤
│ 序号 | 操作类型 | 订单号 | 行号 | 物流链接 | 状态 | 时间 │
│ ────┼──────────┼────────┼──────┼──────────┼──────┼──────── │
│ 1 | 批量同步 | JY123 | 2575 | https... | 成功 | 22:03:30 │
│ 2 | 批量同步 | JY124 | 2576 | https... | 跳过 | 22:03:31 │
│ 3 | 批量同步 | JY125 | 2577 | https... | 失败 | 22:03:32 │
├─────────────────────────────────────────────────────────────────┤
│ 总计 660 条 [10▼] [<] [1] [2] [3] ... [66] [>] │
├─────────────────────────────────────────────────────────────────┤
│ [关闭] [刷新] │
└─────────────────────────────────────────────────────────────────┘
```
---
## 🔍 使用场景
### 场景1查看今天的同步情况
1. 打开日志页面
2. 查看顶部统计卡片
3. 成功数 = 今天成功同步的订单数
4. 跳过数 = 今天跳过的订单数(如已有数据)
5. 失败数 = 今天失败的订单数
### 场景2查找为什么某个订单没有同步
1. 在"订单号"输入框输入订单号,例如:`JY202511061595`
2. 点击"搜索"
3. 查看该订单的操作记录:
- **成功** ✅ - 已经同步
- **跳过** ⚠️ - 被跳过(查看原因)
- **失败** ❌ - 同步失败(查看错误信息)
- **无记录** - 没有尝试同步该订单
### 场景3查看所有失败的订单
1. 在"操作状态"下拉框选择"失败"
2. 点击"搜索"
3. 查看所有失败记录的"错误信息"列
4. 根据错误信息进行处理:
- "未找到订单" → 检查订单是否存在
- "订单物流链接为空" → 补充物流信息
- "API调用失败" → 检查网络或API
### 场景4检查某个订单是否重复推送
1. 在"订单号"输入框输入订单号
2. 点击"搜索"
3. 查看记录数量:
- 只有1条"成功"记录 ✅ - 正常
- 有多条"成功"记录 ❌ - 可能重复推送
- 有"跳过"记录 ✅ - 防重机制生效
---
## 💡 快速技巧
### 技巧1快速刷新
点击底部的 **"刷新"** 按钮即可重新加载最新数据。
### 技巧2查看物流链接
点击"物流链接"列的链接,会在新标签页打开物流链接。
### 技巧3分页查看
- 默认每页显示20条
- 可以选择10/20/50/100条每页
- 使用页码快速跳转
### 技巧4重置筛选
点击 **"重置"** 按钮清除所有搜索条件,显示所有日志。
---
## 🎯 数据解读
### 操作类型
- **批量同步**:通过"批量同步物流"按钮触发的同步
- **单个写入**:单个订单的写入操作(如果有)
### 操作状态
- **成功** (绿色)
- 物流链接已写入腾讯文档
- 订单推送状态已更新为"已推送"
- 操作完全成功
- **跳过** (橙色)
- 订单已推送(数据库标记为已推送)
- 腾讯文档中该行已有物流链接
- 分布式锁获取失败(其他请求正在处理)
- 这是**正常现象**,防止重复推送
- **失败** (红色)
- 未找到订单
- 订单物流链接为空
- 腾讯文档API调用失败
- 其他异常
- 需要**人工处理**
### 统计数字含义
假设统计卡片显示:
```
成功: 150 跳过: 500 失败: 10 总计: 660
```
解读:
- 今天尝试同步了660个订单
- 成功同步150个22.7%
- 跳过500个75.8%- 这些订单可能已经同步过了
- 失败10个1.5%- 需要检查这些订单
**正常情况下:**
- 首次同步成功比例较高60-80%
- 二次同步跳过比例较高80-95%
- 失败比例:应该很低(<5%
---
## 🔧 故障排查
### 问题1看不到日志
**检查步骤:**
1. 是否已经执行过批量同步
2. 后端是否正常运行
3. 数据库表 `tencent_doc_operation_log` 是否有数据
**验证SQL**
```sql
SELECT COUNT(*) FROM tencent_doc_operation_log;
```
### 问题2日志不完整
**可能原因:**
- 后端日志记录失败
- 数据库连接异常
**检查方法:**
查看后端日志中是否有 "记录操作日志失败" 的错误
### 问题3"查看操作日志"按钮点击无反应
**解决方法:**
1. F12 打开浏览器控制台
2. 查看是否有JavaScript错误
3. 清除浏览器缓存后重试
4. 确保前端已重新编译
---
## 📊 数据库直接查询(备用方案)
如果前端页面有问题可以直接查询数据库
### 查看最近50条日志
```sql
SELECT
id,
operation_type AS 操作类型,
order_no AS 订单号,
target_row AS 目标行,
operation_status AS 状态,
error_message AS 错误信息,
operator AS 操作人,
create_time AS 时间
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6' -- 替换为您的fileId
ORDER BY create_time DESC
LIMIT 50;
```
### 查看今天的统计
```sql
SELECT
operation_status AS 状态,
COUNT(*) AS 数量
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6'
AND DATE(create_time) = CURDATE()
GROUP BY operation_status;
```
### 查找某个订单的记录
```sql
SELECT *
FROM tencent_doc_operation_log
WHERE order_no = 'JY202511061595'
ORDER BY create_time DESC;
```
---
## 🎉 使用效果
使用日志查看功能后您可以
**实时监控**随时查看同步状态
**快速定位**找出问题订单
**追溯历史**查看操作记录
**错误诊断**分析失败原因
**效率统计**评估同步效率
---
## 📱 界面按钮位置
```
订单列表页面
顶部操作栏
[搜索] [重置] [导出] [H-TF自动写入配置] [批量同步物流]
配置对话框
底部操作按钮
[查看操作日志] [测试配置] [清除配置] [取消] [保存配置]
日志查看页面 ✨
```
---
**完成!** 🎊
如有问题请查看
- `操作日志查看功能说明.md` - 完整功能文档
- `如何查看同步进度和操作日志.md` - 技术细节
祝使用愉快 😊

View File

@@ -0,0 +1,251 @@
# 腾讯文档操作日志查看功能说明
## 📊 功能概览
新增了一个**操作日志查看页面**,可以方便地查看所有腾讯文档的同步操作记录,包括成功、失败、跳过的记录。
## 🎯 功能特性
### 1. **可视化统计卡片**
- ✅ 成功数量(绿色)
- ⚠️ 跳过数量(橙色)
- ❌ 失败数量(红色)
- 📊 总计数量(蓝色)
### 2. **强大的搜索功能**
- 按订单号搜索
- 按操作类型筛选(批量同步/单个写入)
- 按操作状态筛选(成功/失败/跳过)
### 3. **详细的日志展示**
- 操作类型(带标签)
- 订单号
- 目标行号
- 物流链接(可点击)
- 操作状态(带标签)
- 错误信息
- 操作人
- 操作时间
### 4. **分页功能**
- 支持10/20/50/100条每页
- 总计显示
- 页码跳转
## 📍 如何使用
### 方法1从配置页面打开推荐
1. 打开订单列表页面
2. 点击 **"H-TF自动写入配置"** 按钮
3. 在配置对话框底部,点击 **"查看操作日志"** 按钮(蓝色)
4. 即可查看当前文档的所有操作日志
### 方法2直接在列表页面添加按钮可选
如果需要,也可以在订单列表页面添加一个独立的"查看日志"按钮。
## 🔍 日志搜索示例
### 示例1查看某个订单的操作记录
1. 在"订单号"输入框输入:`JY202511061595`
2. 点击"搜索"
3. 查看该订单的所有操作历史
### 示例2查看今天失败的操作
1. 在"操作状态"下拉框选择:`失败`
2. 点击"搜索"
3. 查看所有失败的记录和错误信息
### 示例3查看批量同步的记录
1. 在"操作类型"下拉框选择:`批量同步`
2. 点击"搜索"
3. 查看所有批量同步的操作
## 📊 统计卡片说明
页面顶部的统计卡片会**实时计算**当前筛选条件下的数据:
- **成功**:操作成功完成的数量
- **跳过**:因为各种原因跳过的数量(如已有数据、已推送等)
- **失败**:操作失败的数量
- **总计**:所有记录的总数
## 🎨 界面截图说明
### 统计卡片区域
```
┌─────────────┬─────────────┬─────────────┬─────────────┐
│ 成功 │ 跳过 │ 失败 │ 总计 │
│ 150 │ 500 │ 10 │ 660 │
│ (绿色) │ (橙色) │ (红色) │ (蓝色) │
└─────────────┴─────────────┴─────────────┴─────────────┘
```
### 日志表格
```
序号 | 操作类型 | 订单号 | 目标行 | 物流链接 | 状态 | 错误信息 | 操作人 | 操作时间
-----|----------|--------|--------|----------|------|----------|--------|----------
1 | 批量同步 | JY123 | 2575 | https... | 成功 | - | admin | 22:03:30
2 | 批量同步 | JY124 | 2576 | https... | 跳过 | 已有数据 | admin | 22:03:30
```
## 🔧 技术实现
### 后端接口
**1. 查询操作日志列表**
```
GET /jarvis-api/jarvis/tendoc/operationLogs
参数:
- fileId: 文件ID可选
- sheetId: 工作表ID可选
- orderNo: 订单号(可选)
- operationType: 操作类型(可选)
- operationStatus: 操作状态(可选)
```
**2. 查询最近的操作日志**
```
GET /jarvis-api/jarvis/tendoc/recentLogs
参数:
- fileId: 文件ID可选
- limit: 限制数量默认50
```
### 前端组件
- **组件位置**`src/views/system/jdorder/components/TencentDocOperationLogs.vue`
- **组件名称**`TencentDocOperationLogs`
- **依赖API**`@/api/jarvis/tendoc.js`
## 📝 操作状态说明
### SUCCESS成功
- 物流链接成功写入腾讯文档
- 订单状态已更新
- 操作日志已记录
### FAILED失败
可能的原因:
- 未找到订单
- 订单物流链接为空
- API调用失败
- 写入异常
### SKIPPED跳过
可能的原因:
- 订单已推送(`tencent_doc_pushed = 1`
- 腾讯文档中该行已有物流链接
- 分布式锁获取失败
## 🚀 性能优化建议
1. **定期清理历史日志**
```sql
-- 清理30天前的日志
DELETE FROM tencent_doc_operation_log
WHERE create_time < DATE_SUB(NOW(), INTERVAL 30 DAY);
```
2. **添加索引优化查询**
```sql
-- 已创建的索引
CREATE INDEX idx_file_id ON tencent_doc_operation_log(file_id);
CREATE INDEX idx_order_no ON tencent_doc_operation_log(order_no);
CREATE INDEX idx_create_time ON tencent_doc_operation_log(create_time);
```
## 🔒 权限控制
目前日志查看功能没有单独的权限控制,与腾讯文档配置功能共用权限。
如需单独控制,可以:
1. 在后端Controller添加权限注解
2. 在前端路由配置中添加权限判断
## 📱 响应式设计
日志查看对话框支持响应式设计:
- 宽度90%(自适应屏幕)
- 表格最大高度500px自动滚动
- 分页:右对齐,自适应
## ❓ 常见问题
### Q1: 为什么看不到日志?
**A:** 可能原因:
1. 还没有执行过批量同步
2. fileId或sheetId参数不正确
3. 后端接口异常
**解决方法:**
- 先执行一次批量同步
- 检查后端日志是否有错误
- 检查数据库表 `tencent_doc_operation_log` 是否有数据
### Q2: 统计数字不准确?
**A:** 统计是基于**当前筛选条件**计算的,不是所有数据的统计。
例如:
- 如果筛选了"失败"状态,统计卡片只会统计失败的记录
- 重置筛选条件后,统计会更新
### Q3: 能否导出日志?
**A:** 当前版本不支持导出,但可以直接从数据库导出:
```sql
-- 导出CSV格式
SELECT
operation_type,
order_no,
target_row,
logistics_link,
operation_status,
error_message,
operator,
create_time
INTO OUTFILE '/tmp/tendoc_logs.csv'
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6'
ORDER BY create_time DESC;
```
### Q4: 如何清空所有日志?
**A:** 谨慎操作执行以下SQL
```sql
-- 清空指定文档的日志
DELETE FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6';
-- 清空所有日志(慎用!)
TRUNCATE TABLE tencent_doc_operation_log;
```
## 🎉 使用效果
使用操作日志功能后,您可以:
1. ✅ **实时监控**同步状态
2. ✅ **快速定位**问题订单
3. ✅ **追溯历史**操作记录
4. ✅ **统计分析**同步效率
5. ✅ **错误诊断**失败原因
---
**最后更新**: 2025-11-06 22:50
**版本**: v1.0
**作者**: AI Assistant

178
nginx-https.conf Normal file
View File

@@ -0,0 +1,178 @@
# WebSocket连接升级映射必须在server块之前定义
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 80端口仅处理HTTP请求自动重定向到HTTPS
server {
listen 80;
server_name jarvis.van333.cn; # 匹配域名
# 核心HTTP请求永久重定向到HTTPS301表示永久重定向
return 301 https://$host$request_uri;
# 可选:记录重定向日志(便于排查)
access_log /www/sites/jarvis.van333.cn/log/redirect.log main;
}
# 443端口处理HTTPS请求包含SSL配置和业务逻辑
server {
listen 443 ssl;
server_name jarvis.van333.cn; # 与80端口保持一致的域名
# 网站根目录和默认首页(保留你的业务配置)
root /www/sites/jarvis.van333.cn/index;
index index.html index.htm;
# SSL证书配置仅在443端口生效
ssl_certificate /www/common/ssl/jarvis.van333.cn/fullchain.cer;
ssl_certificate_key /www/common/ssl/jarvis.van333.cn.key;
# SSL安全配置复用你的原有配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
# 日志配置
access_log /www/sites/jarvis.van333.cn/log/access.log main;
error_log /www/sites/jarvis.van333.cn/log/error.log;
# 静态资源缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# ========== 重要后端API代理配置 ==========
# 将所有API请求代理到后端服务器解决混合内容问题
# 注意:这里的路径需要与前端 VUE_APP_BASE_API 配置一致
# ^~ 表示命中前缀后不再尝试正则 location避免被其它规则干扰
location ^~ /jarvis-api/ {
proxy_pass http://127.0.0.1:30313/; # 后端服务地址
# 请求头设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
# 勿手动设置 Content-Type / Content-Lengthmultipart 上传必须原样转发(含 boundary
# 且分块请求时 $content_length 可能为空会导致后端报「not a multipart request」。
proxy_pass_request_headers on;
proxy_pass_request_body on;
# HTTP版本和WebSocket支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 超时设置
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# 请求缓冲设置(对大文件上传有用)
proxy_request_buffering on;
client_max_body_size 100M;
}
# OAuth 回调:须放在 /jarvis-api/ 之后、location / 之前。
# 切勿重复定义同名 location例如两个 /wps365-callback仅第一个生效
# 腾讯文档 OAuth 回调
location /tendoc-callback {
proxy_pass http://127.0.0.1:30313/tendoc-callback;
# 请求头设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_pass_request_headers on;
proxy_pass_request_body on;
# HTTP版本
proxy_http_version 1.1;
# 超时设置
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# 请求体大小限制
client_max_body_size 100M;
}
# 金山文档:旧回调路径(后端 302 → /kdocs-callback仅过渡用后台建议改为 /kdocs-callback
location /wps365-callback {
proxy_pass http://127.0.0.1:30313/wps365-callback;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_pass_request_headers on;
proxy_pass_request_body on;
proxy_http_version 1.1;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
client_max_body_size 100M;
}
# 金山文档 OAuth 回调(与 kdocs.redirect-uri、开放平台登记一致
location /kdocs-callback {
proxy_pass http://127.0.0.1:30313/kdocs-callback;
# 请求头设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_pass_request_headers on;
proxy_pass_request_body on;
# HTTP版本
proxy_http_version 1.1;
# 超时设置
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# 请求体大小限制
client_max_body_size 100M;
}
# 注意jarvis相关API已通过 /jarvis-api/ 代理,不再需要单独的 /jarvis/ location
# Druid监控代理如果需要
location /druid/ {
proxy_pass http://127.0.0.1:30313/druid/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Vue Router History模式支持必须放在最后
location / {
try_files $uri $uri/ /index.html;
}
# 404错误页面
error_page 404 /404.html;
}

View File

@@ -6,6 +6,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vue-cli-service serve", "dev": "vue-cli-service serve",
"prod": "vue-cli-service build",
"build:prod": "vue-cli-service build", "build:prod": "vue-cli-service build",
"build:stage": "vue-cli-service build --mode staging", "build:stage": "vue-cli-service build --mode staging",
"preview": "node build/index.js --preview" "preview": "node build/index.js --preview"
@@ -37,6 +38,7 @@
"js-cookie": "3.0.1", "js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1", "jsencrypt": "3.0.0-rc.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinyin-pro": "^3.27.0",
"quill": "2.0.2", "quill": "2.0.2",
"screenfull": "5.0.2", "screenfull": "5.0.2",
"sortablejs": "1.10.2", "sortablejs": "1.10.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -4,7 +4,7 @@
<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="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, minimum-scale=1, user-scalable=yes, viewport-fit=cover">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= webpackConfig.name %></title> <title><%= webpackConfig.name %></title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]--> <!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->

View File

@@ -1,16 +1,16 @@
<template> <template>
<div id="app"> <div id="app">
<router-view /> <router-view />
<theme-picker /> <!-- <theme-picker /> -->
</div> </div>
</template> </template>
<script> <script>
import ThemePicker from "@/components/ThemePicker" // import ThemePicker from "@/components/ThemePicker"
export default { export default {
name: "App", name: "App"
components: { ThemePicker } // components: { ThemePicker }
} }
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 解析线报消息
export function parseLineReport(data) {
return request({
url: '/jarvis/batchPublish/parse',
method: 'post',
data: data
})
}
// 批量发品
export function batchPublish(data) {
return request({
url: '/jarvis/batchPublish/publish',
method: 'post',
data: data
})
}
// 查询批量发品任务列表
export function listTasks(query) {
return request({
url: '/jarvis/batchPublish/task/list',
method: 'get',
params: query
})
}
// 查询批量发品任务详情
export function getTask(taskId) {
return request({
url: '/jarvis/batchPublish/task/' + taskId,
method: 'get'
})
}
// 查询批量发品明细列表
export function listItems(taskId) {
return request({
url: '/jarvis/batchPublish/item/list/' + taskId,
method: 'get'
})
}
// 手动重试任务
export function retryTask(taskId) {
return request({
url: '/jarvis/batchPublish/task/retry/' + taskId,
method: 'post'
})
}

154
src/api/jarvis/comment.js Normal file
View File

@@ -0,0 +1,154 @@
import request from '@/utils/request'
// 查询京东评论列表
export function listJdComment(query) {
return request({
url: '/jarvis/comment/jd/list',
method: 'get',
params: query
})
}
// 查询京东评论详细
export function getJdComment(id) {
return request({
url: '/jarvis/comment/jd/' + id,
method: 'get'
})
}
// 修改京东评论
export function updateJdComment(data) {
return request({
url: '/jarvis/comment/jd',
method: 'put',
data: data
})
}
// 删除京东评论
export function delJdComment(ids) {
return request({
url: '/jarvis/comment/jd/' + ids,
method: 'delete'
})
}
// 重置京东评论使用状态
export function resetJdCommentByProductId(productId) {
return request({
url: '/jarvis/comment/jd/reset/' + productId,
method: 'put'
})
}
// 查询淘宝评论列表
export function listTbComment(query) {
return request({
url: '/jarvis/taobaoComment/list',
method: 'get',
params: query
})
}
// 查询淘宝评论详细
export function getTbComment(id) {
return request({
url: '/jarvis/taobaoComment/' + id,
method: 'get'
})
}
// 修改淘宝评论
export function updateTbComment(data) {
return request({
url: '/jarvis/taobaoComment',
method: 'put',
data: data
})
}
// 删除淘宝评论
export function delTbComment(ids) {
return request({
url: '/jarvis/taobaoComment/' + ids,
method: 'delete'
})
}
// 重置淘宝评论使用状态
export function resetTbCommentByProductId(productId) {
return request({
url: '/jarvis/taobaoComment/reset/' + productId,
method: 'put'
})
}
// 获取评论统计信息
export function getCommentStatistics(source) {
return request({
url: '/jarvis/comment/statistics',
method: 'get',
params: { source }
})
}
// 获取接口调用统计
export function getApiStatistics(query) {
return request({
url: '/jarvis/comment/api/statistics',
method: 'get',
params: query
})
}
// 获取Redis产品类型映射京东
export function getJdProductTypeMap() {
return request({
url: '/jarvis/comment/redis/jd/map',
method: 'get'
})
}
// 获取Redis产品类型映射淘宝
export function getTbProductTypeMap() {
return request({
url: '/jarvis/comment/redis/tb/map',
method: 'get'
})
}
// 获取评论(由后端转发到外部服务,避免前端跨域)
export function fetchComments(productId) {
return request({
url: '/jarvis/comment/fetch-comments',
method: 'get',
params: { product_id: productId }
})
}
// 获取当前IP地址公开接口
export function getCurrentIP() {
return request({
url: '/public/comment/ip',
method: 'get'
})
}
// 获取评论生成历史记录(公开接口)
export function getCommentHistory(query) {
return request({
url: '/public/comment/history',
method: 'get',
params: query
})
}
// 获取评论生成使用统计(公开接口)
export function getCommentUsageStatistics() {
return request({
url: '/public/comment/usage-statistics',
method: 'get'
})
}

86
src/api/jarvis/goofish.js Normal file
View File

@@ -0,0 +1,86 @@
import request from '@/utils/request'
/* ---------- 闲管家应用配置 ---------- */
export function listErpOpenConfig(query) {
return request({ url: '/jarvis/erpOpenConfig/list', method: 'get', params: query })
}
export function getErpOpenConfig(id) {
return request({ url: '/jarvis/erpOpenConfig/' + id, method: 'get' })
}
export function addErpOpenConfig(data) {
return request({ url: '/jarvis/erpOpenConfig', method: 'post', data })
}
export function updateErpOpenConfig(data) {
return request({ url: '/jarvis/erpOpenConfig', method: 'put', data })
}
export function delErpOpenConfig(ids) {
return request({ url: '/jarvis/erpOpenConfig/' + ids, method: 'delete' })
}
/** 闲管家「查询快递公司」(与 Apifox 一致:服务端 POST {} + 签名) */
export function listGoofishExpressCompanies(appKey) {
return request({
url: '/jarvis/erpOpenConfig/expressCompanies',
method: 'get',
params: appKey ? { appKey } : {}
})
}
/* ---------- 闲管家订单 ---------- */
export function listGoofishOrder(query) {
return request({ url: '/jarvis/erpGoofishOrder/list', method: 'get', params: query })
}
export function getGoofishOrder(id) {
return request({ url: '/jarvis/erpGoofishOrder/' + id, method: 'get' })
}
/** 订单状态 / 物流 / 发货 变更日志 */
export function listGoofishOrderEventLogs(orderId) {
return request({ url: '/jarvis/erpGoofishOrder/' + orderId + '/eventLogs', method: 'get' })
}
/** 变更日志全表分页(排查:与 [goofish-order-event] 落库同源) */
export function listGoofishOrderEventLogPage(query) {
return request({ url: '/jarvis/erpGoofishOrder/eventLog/list', method: 'get', params: query })
}
export function pullGoofishOrders(appKey, hours) {
return request({
url: '/jarvis/erpGoofishOrder/pull/' + encodeURIComponent(appKey),
method: 'post',
params: hours != null ? { hours } : {}
})
}
export function pullAllGoofishOrders(hours) {
return request({
url: '/jarvis/erpGoofishOrder/pullAll',
method: 'post',
params: hours != null ? { hours } : {}
})
}
/** 历史全量:按服务端 pull-full-history-days + 时间分段拉 update_time */
export function pullGoofishOrdersFull(appKey) {
return request({
url: '/jarvis/erpGoofishOrder/pull/' + encodeURIComponent(appKey) + '/full',
method: 'post'
})
}
export function pullAllGoofishOrdersFull() {
return request({ url: '/jarvis/erpGoofishOrder/pullAll/full', method: 'post' })
}
export function refreshGoofishDetail(id) {
return request({ url: '/jarvis/erpGoofishOrder/refreshDetail/' + id, method: 'post' })
}
export function retryGoofishShip(id) {
return request({ url: '/jarvis/erpGoofishOrder/retryShip/' + id, method: 'post' })
}

97
src/api/jarvis/kdocs.js Normal file
View File

@@ -0,0 +1,97 @@
import request from '@/utils/request'
export function getKdocsAuthUrl(state) {
return request({
url: '/jarvis/kdocs/authUrl',
method: 'get',
params: state ? { state } : {}
})
}
export function getKdocsTokenStatus(userId) {
return request({
url: '/jarvis/kdocs/tokenStatus',
method: 'get',
params: { userId }
})
}
export function refreshKdocsToken(data) {
return request({
url: '/jarvis/kdocs/refreshToken',
method: 'post',
data
})
}
export function setKdocsToken(data) {
return request({
url: '/jarvis/kdocs/setToken',
method: 'post',
data
})
}
export function getKdocsUserInfo(userId) {
return request({
url: '/jarvis/kdocs/userInfo',
method: 'get',
params: { userId }
})
}
export function getKdocsFileList(params) {
return request({
url: '/jarvis/kdocs/files',
method: 'get',
params
})
}
export function getKdocsFileInfo(userId, fileToken) {
return request({
url: '/jarvis/kdocs/fileInfo',
method: 'get',
params: { userId, fileToken }
})
}
export function getKdocsSheetList(userId, fileToken) {
return request({
url: '/jarvis/kdocs/sheets',
method: 'get',
params: { userId, fileToken }
})
}
export function createKdocsSheet(data) {
return request({
url: '/jarvis/kdocs/createSheet',
method: 'post',
data
})
}
export function readKdocsCells(params) {
return request({
url: '/jarvis/kdocs/readCells',
method: 'get',
params
})
}
export function updateKdocsCells(data) {
return request({
url: '/jarvis/kdocs/updateCells',
method: 'post',
data
})
}
export function batchUpdateKdocsCells(data) {
return request({
url: '/jarvis/kdocs/batchUpdateCells',
method: 'post',
data
})
}

View File

@@ -0,0 +1,20 @@
import request from '@/utils/request'
// 生成单张营销图片
export function generateMarketingImage(data) {
return request({
url: '/jarvis/marketing-image/generate',
method: 'post',
data: data
})
}
// 批量生成营销图片
export function batchGenerateMarketingImages(data) {
return request({
url: '/jarvis/marketing-image/batch-generate',
method: 'post',
data: data
})
}

View File

@@ -0,0 +1,43 @@
import request from '@/utils/request'
// 获取指定类型的手机号列表
export function getPhoneList(type) {
return request({
url: '/jarvis/phoneReplaceConfig/' + type,
method: 'get'
})
}
// 设置指定类型的手机号列表
export function setPhoneList(type, phoneList) {
return request({
url: '/jarvis/phoneReplaceConfig/' + type,
method: 'put',
data: phoneList
})
}
// 添加手机号到指定类型
export function addPhone(type, phone) {
return request({
url: '/jarvis/phoneReplaceConfig/' + type + '/add',
method: 'post',
data: phone,
headers: {
'Content-Type': 'application/json'
}
})
}
// 从指定类型删除手机号
export function removePhone(type, phone) {
return request({
url: '/jarvis/phoneReplaceConfig/' + type + '/remove',
method: 'post',
data: phone,
headers: {
'Content-Type': 'application/json'
}
})
}

View File

@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 查询产品京东配置列表
export function listProductJdConfig(query) {
return request({
url: '/jarvis/productJdConfig/list',
method: 'get',
params: query
})
}
// 查询产品京东配置详细
export function getProductJdConfig(productModel) {
return request({
url: '/jarvis/productJdConfig/' + productModel,
method: 'get'
})
}
// 新增产品京东配置
export function addProductJdConfig(data) {
return request({
url: '/jarvis/productJdConfig',
method: 'post',
data: data
})
}
// 修改产品京东配置
export function updateProductJdConfig(data) {
return request({
url: '/jarvis/productJdConfig',
method: 'put',
data: data
})
}
// 删除产品京东配置
export function delProductJdConfig(productModels) {
return request({
url: '/jarvis/productJdConfig/' + productModels,
method: 'delete'
})
}
// 初始化默认数据
export function initDefaultData() {
return request({
url: '/jarvis/productJdConfig/initData',
method: 'post'
})
}

View File

@@ -0,0 +1,38 @@
import request from '@/utils/request'
// 提取关键词
export function extractKeywords(data) {
return request({
url: '/jarvis/social-media/extract-keywords',
method: 'post',
data: data
})
}
// 生成文案
export function generateContent(data) {
return request({
url: '/jarvis/social-media/generate-content',
method: 'post',
data: data
})
}
// 一键生成完整内容(关键词 + 文案 + 图片)
export function generateComplete(data) {
return request({
url: '/jarvis/social-media/generate-complete',
method: 'post',
data: data
})
}
// 闲鱼文案(手动):根据标题+可选型号生成代下单、教你下单文案
export function generateXianyuWenan(data) {
return request({
url: '/jarvis/social-media/xianyu-wenan/generate',
method: 'post',
data: data
})
}

View File

@@ -0,0 +1,102 @@
import request from '@/utils/request'
// 获取提示词模板列表
export function listPromptTemplates() {
return request({
url: '/jarvis/social-media/prompt/list',
method: 'get'
})
}
// 获取单个提示词模板key 含冒号时需编码)
export function getPromptTemplate(key) {
return request({
url: '/jarvis/social-media/prompt/' + encodeURIComponent(key),
method: 'get'
})
}
// 保存提示词模板
export function savePromptTemplate(data) {
return request({
url: '/jarvis/social-media/prompt/save',
method: 'post',
data
})
}
// 删除提示词模板(恢复默认)
export function deletePromptTemplate(key) {
return request({
url: '/jarvis/social-media/prompt/' + encodeURIComponent(key),
method: 'delete'
})
}
// —— 多套大模型接入(与 Jarvis 共用 Redis——
export function listLlmProfiles() {
return request({
url: '/jarvis/social-media/llm-config',
method: 'get'
})
}
export function getLlmProfile(id) {
return request({
url: '/jarvis/social-media/llm-config/profiles/' + encodeURIComponent(id),
method: 'get'
})
}
export function createLlmProfile(data) {
return request({
url: '/jarvis/social-media/llm-config/profiles',
method: 'post',
data
})
}
export function updateLlmProfile(id, data) {
return request({
url: '/jarvis/social-media/llm-config/profiles/' + encodeURIComponent(id),
method: 'put',
data
})
}
export function deleteLlmProfile(id) {
return request({
url: '/jarvis/social-media/llm-config/profiles/' + encodeURIComponent(id),
method: 'delete'
})
}
export function setActiveLlmProfile(id) {
return request({
url: '/jarvis/social-media/llm-config/active/' + encodeURIComponent(id),
method: 'put'
})
}
export function clearActiveLlmProfile() {
return request({
url: '/jarvis/social-media/llm-config/active',
method: 'delete'
})
}
export function resetAllLlmConfig() {
return request({
url: '/jarvis/social-media/llm-config',
method: 'delete'
})
}
/** 连通测试:可选 profileId、message不传 profileId 则用 Jarvis 当前激活/默认 */
export function testLlmProfile(data) {
return request({
url: '/jarvis/social-media/llm-config/test',
method: 'post',
data: data || {}
})
}

243
src/api/jarvis/tendoc.js Normal file
View File

@@ -0,0 +1,243 @@
import request from '@/utils/request'
// 获取腾讯文档授权URL
export function getTencentDocAuthUrl() {
return request({
url: '/jarvis/tendoc/authUrl',
method: 'get'
})
}
// OAuth回调获取访问令牌
export function getTencentDocAccessToken(code) {
return request({
url: '/jarvis/tendoc/oauth/callback',
method: 'get',
params: { code }
})
}
// 刷新访问令牌
export function refreshTencentDocToken(data) {
return request({
url: '/jarvis/tendoc/refreshToken',
method: 'post',
data
})
}
// 填充单个订单的物流链接(直接传单号和物流链接)
export function fillSingleLogistics(thirdPartyOrderNo, logisticsLink) {
return request({
url: '/jarvis/tendoc/fillSingleLogistics',
method: 'post',
data: { thirdPartyOrderNo, logisticsLink }
})
}
// 批量同步物流链接(从数据库读取订单物流信息并填充到表格)
export function fillLogisticsByOrderNo(data) {
return request({
url: '/jarvis/tendoc/fillLogisticsByOrderNo',
method: 'post',
data
})
}
// 获取token状态
export function getTokenStatus() {
return request({
url: '/jarvis/tendoc/tokenStatus',
method: 'get'
})
}
// 设置token用于首次授权
export function setToken(data) {
return request({
url: '/jarvis/tendoc/setToken',
method: 'post',
data
})
}
// 追加单个订单物流信息
export function appendLogistics(data) {
return request({
url: '/jarvis/tendoc/appendLogistics',
method: 'post',
data
})
}
// 自动发货
export function autoShip(data) {
return request({
url: '/jarvis/tendoc/autoShip',
method: 'post',
data
})
}
// 读取表格数据
export function readSheetData(params) {
return request({
url: '/jarvis/tendoc/readSheet',
method: 'get',
params
})
}
// 获取文件信息
export function getFileInfo(params) {
return request({
url: '/jarvis/tendoc/fileInfo',
method: 'get',
params
})
}
// 获取工作表列表
export function getSheetList(params) {
return request({
url: '/jarvis/tendoc/sheetList',
method: 'get',
params
})
}
// 测试获取用户信息
export function testUserInfo() {
return request({
url: '/jarvis/tendoc/testUserInfo',
method: 'get'
})
}
// ==================== H-TF订单自动写入配置接口 ====================
// 获取自动写入配置
export function getAutoWriteConfig() {
return request({
url: '/jarvis/tencentDoc/config',
method: 'get'
})
}
// 更新自动写入配置
export function updateAutoWriteConfig(data) {
return request({
url: '/jarvis/tencentDoc/config',
method: 'post',
data
})
}
// 测试配置是否有效
export function testAutoWriteConfig() {
return request({
url: '/jarvis/tencentDoc/config/test',
method: 'get'
})
}
// 清除自动写入配置
export function clearAutoWriteConfig() {
return request({
url: '/jarvis/tencentDoc/config',
method: 'delete'
})
}
// 获取文档的工作表列表
export function getDocSheetList(fileId) {
return request({
url: '/jarvis/tencentDoc/config/sheets',
method: 'get',
params: { fileId }
})
}
// 查询操作日志列表
export function getOperationLogs(params) {
return request({
url: '/jarvis/tendoc/operationLogs',
method: 'get',
params
})
}
// 查询最近的操作日志
export function getRecentLogs(params) {
return request({
url: '/jarvis/tendoc/recentLogs',
method: 'get',
params
})
}
// ==================== 批量推送记录相关 ====================
/**
* 获取批量推送记录列表
*/
export function getBatchPushRecords(params) {
return request({
url: '/jarvis/tendoc/batchPushRecords',
method: 'get',
params
})
}
/**
* 获取批量推送记录详情
*/
export function getBatchPushRecordDetail(batchId) {
return request({
url: `/jarvis/tendoc/batchPushRecord/${batchId}`,
method: 'get'
})
}
/**
* 获取推送状态和倒计时信息
*/
export function getPushStatus() {
return request({
url: '/jarvis/tendoc/pushStatus',
method: 'get'
})
}
/**
* 手动触发立即推送
*/
export function triggerPushNow() {
return request({
url: '/jarvis/tendoc/triggerPushNow',
method: 'post'
})
}
/**
* 取消待推送任务
*/
export function cancelPendingPush() {
return request({
url: '/jarvis/tendoc/cancelPendingPush',
method: 'post'
})
}
/**
* 反向同步第三方单号
* 从腾讯文档的物流单号列读取链接,通过链接匹配本地订单,将腾讯文档的单号列值写入到订单的第三方单号字段
*/
export function reverseSyncThirdPartyOrderNo(data) {
return request({
url: '/jarvis/tendoc/reverseSyncThirdPartyOrderNo',
method: 'post',
data
})
}

View File

@@ -0,0 +1,32 @@
import request from '@/utils/request'
export function listWecomInboundTrace(query) {
return request({
url: '/jarvis/wecom/inboundTrace/list',
method: 'get',
params: query
})
}
export function getWecomInboundTrace(id) {
return request({
url: '/jarvis/wecom/inboundTrace/' + id,
method: 'get'
})
}
export function delWecomInboundTrace(ids) {
return request({
url: '/jarvis/wecom/inboundTrace/' + (Array.isArray(ids) ? ids.join(',') : ids),
method: 'delete'
})
}
/** 清理测试数据(追踪表 + 可选 Redis */
export function cleanWecomInboundTraceTestData(data) {
return request({
url: '/jarvis/wecom/inboundTrace/cleanTestData',
method: 'post',
data: data || {}
})
}

View File

@@ -0,0 +1,57 @@
import request from '@/utils/request'
export function listWecomShareLinkLogisticsJob(query) {
return request({
url: '/jarvis/wecom/shareLinkLogisticsJob/list',
method: 'get',
params: query
})
}
export function getWecomShareLinkLogisticsJob(jobKey) {
return request({
url: '/jarvis/wecom/shareLinkLogisticsJob/' + encodeURIComponent(jobKey),
method: 'get'
})
}
export function backfillShareLinkLogisticsFromTrace() {
return request({
url: '/jarvis/wecom/shareLinkLogisticsJob/backfillFromInboundTrace',
method: 'post'
})
}
/** 与订单列表「获取物流」一致:立即查物流并推送 */
export function fetchShareLinkManually(data) {
return request({
url: '/jarvis/wecom/shareLinkLogisticsJob/fetchShareLinkManually',
method: 'post',
data
})
}
/** 手动执行一轮 Redis 待扫描队列(同定时任务) */
export function drainShareLinkPendingQueueOnce() {
return request({
url: '/jarvis/wecom/shareLinkLogisticsJob/drainPendingQueueOnce',
method: 'post'
})
}
/** 取消扫描:状态改为 CANCELLED不再对账入队队列弹出时跳过 */
export function cancelWecomShareLinkJob(data) {
return request({
url: '/jarvis/wecom/shareLinkLogisticsJob/cancel',
method: 'post',
data
})
}
/** 删除任务行Redis 中残留同 jobKey 项弹出时会因无库行而跳过) */
export function removeWecomShareLinkJob(jobKey) {
return request({
url: '/jarvis/wecom/shareLinkLogisticsJob/' + encodeURIComponent(jobKey),
method: 'delete'
})
}

View File

@@ -0,0 +1,22 @@
import request from '@/utils/request'
/** 获取可选的日志文件列表 */
export function listLogfiles() {
return request({
url: '/monitor/logfile/list',
method: 'get'
})
}
/**
* 读取日志文件末尾 N 行HTTPS 轮询,无需 WebSocket
* @param {string} file - 文件名,如 sys-info.log
* @param {number} lines - 行数,默认 500最大 5000
*/
export function tailLogfile(file, lines = 500) {
return request({
url: '/monitor/logfile/tail',
method: 'get',
params: { file, lines }
})
}

View File

@@ -6,4 +6,28 @@ export function getServer() {
url: '/monitor/server', url: '/monitor/server',
method: 'get' method: 'get'
}) })
}
// 获取服务健康度检测
export function getHealth() {
return request({
url: '/monitor/server/health',
method: 'get'
})
}
// 手动测试微信推送(会真实下发一条消息)
export function triggerWxSendHealthTest() {
return request({
url: '/monitor/server/health/wx-send-test',
method: 'post'
})
}
// 手动测试企微闲鱼通知(经 wxSend goofish-active-push
export function triggerGoofishNotifyTest() {
return request({
url: '/monitor/server/health/goofish-notify-test',
method: 'post'
})
} }

23
src/api/public/order.js Normal file
View File

@@ -0,0 +1,23 @@
import request from '@/utils/request'
// 提交公开订单
export function submitPublicOrder(data) {
return request({
url: '/public/order/submit',
method: 'post',
data
})
}
// 强制提交公开订单带forceGenerate参数
export function submitPublicOrderWithForce(data) {
return request({
url: '/public/order/submit',
method: 'post',
data: {
...data,
forceGenerate: true
}
})
}

View File

@@ -0,0 +1,98 @@
import request from '@/utils/request'
// 列表
export function listErpProduct(query) {
return request({
url: '/jarvis/erpProduct/list',
method: 'get',
params: query
})
}
// 详情
export function getErpProduct(id) {
return request({
url: `/jarvis/erpProduct/${id}`,
method: 'get'
})
}
// 新增
export function addErpProduct(data) {
return request({
url: '/jarvis/erpProduct',
method: 'post',
data
})
}
// 修改
export function updateErpProduct(data) {
return request({
url: '/jarvis/erpProduct',
method: 'put',
data
})
}
// 删除
export function delErpProduct(ids) {
return request({
url: `/jarvis/erpProduct/${ids}`,
method: 'delete'
})
}
// 拉取商品列表(单页,兼容)
export function pullProductList(data) {
return request({
url: '/jarvis/erpProduct/pull',
method: 'post',
params: data
})
}
// 全量同步商品(自动遍历所有页码)
export function syncAllProducts(data) {
return request({
url: '/jarvis/erpProduct/syncAll',
method: 'post',
params: data
})
}
// 批量上架
export function batchPublish(data) {
return request({
url: '/erp/product/batchPublish',
method: 'post',
data
})
}
// 批量下架
export function batchDownShelf(data) {
return request({
url: '/erp/product/batchDownShelf',
method: 'post',
data
})
}
// 获取ERP账号列表
export function getERPAccounts() {
return request({
url: '/erp/product/ERPAccount',
method: 'get'
})
}
// 获取授权的闲鱼会员名下拉
export function getUsernames(query) {
return request({
url: '/erp/product/usernames',
method: 'get',
params: query
})
}

View File

@@ -0,0 +1,95 @@
import request from '@/utils/request'
// 列表
export function listFavoriteProduct(query) {
return request({
url: '/jarvis/favoriteProduct/list',
method: 'get',
params: query
})
}
// 详情
export function getFavoriteProduct(id) {
return request({
url: `/jarvis/favoriteProduct/${id}`,
method: 'get'
})
}
// 新增
export function addFavoriteProduct(data) {
return request({
url: '/jarvis/favoriteProduct',
method: 'post',
data
})
}
// 修改
export function updateFavoriteProduct(data) {
return request({
url: '/jarvis/favoriteProduct',
method: 'put',
data
})
}
// 删除
export function delFavoriteProduct(ids) {
return request({
url: `/jarvis/favoriteProduct/${ids}`,
method: 'delete'
})
}
// 更新置顶状态
export function updateTopStatus(id, isTop) {
return request({
url: `/jarvis/favoriteProduct/updateTopStatus/${id}/${isTop}`,
method: 'put'
})
}
// 添加到常用商品
export function addToFavorites(data) {
return request({
url: '/jarvis/favoriteProduct/addToFavorites',
method: 'post',
data
})
}
// 根据skuid查询是否已存在
export function getBySkuid(skuid) {
return request({
url: `/jarvis/favoriteProduct/getBySkuid/${encodeURIComponent(skuid)}`,
method: 'get'
})
}
// 用户的常用商品列表
export function getUserFavorites() {
return request({
url: '/jarvis/favoriteProduct/userFavorites',
method: 'get'
})
}
// 从常用商品快速发品
export function quickPublishFromFavorite(id, appid) {
return request({
url: `/jarvis/favoriteProduct/quickPublish/${id}`,
method: 'post',
data: appid
})
}
// 更新发品信息商品ID、状态等
export function updateProductInfo(data) {
return request({
url: '/jarvis/favoriteProduct/updateProductInfo',
method: 'put',
data
})
}

View File

@@ -0,0 +1,37 @@
import request from '@/utils/request'
// 礼金列表
export function listGiftCoupons(query) {
return request({
url: '/system/giftcoupon/list',
method: 'get',
params: query
})
}
// 礼金详情(包含关联订单)
export function getGiftCoupon(giftCouponKey) {
return request({
url: `/system/giftcoupon/${giftCouponKey}`,
method: 'get'
})
}
// 礼金统计
export function getGiftCouponStatistics(query) {
return request({
url: '/system/giftcoupon/statistics',
method: 'get',
params: query
})
}
// 导出礼金列表
export function exportGiftCoupons(query) {
return request({
url: '/system/giftcoupon/export',
method: 'post',
params: query
})
}

View File

@@ -0,0 +1,40 @@
import request from '@/utils/request'
export function executeInstruction(data) {
return request({
url: '/jarvis/instruction/execute',
method: 'post',
data
})
}
export function executeInstructionWithForce(data) {
return request({
url: '/jarvis/instruction/execute',
method: 'post',
data: {
...data,
forceGenerate: true
}
})
}
/**
* 获取历史消息记录
* @param type request | response
* @param limit 条数上限
* @param keyword 可选,搜索关键词;传则后端在全部数据中过滤后返回
*/
export function getHistory(type, limit, keyword) {
const params = { type, limit }
if (keyword != null && String(keyword).trim() !== '') {
params.keyword = String(keyword).trim()
}
return request({
url: '/jarvis/instruction/history',
method: 'get',
params
})
}

View File

@@ -1,4 +1,32 @@
import request from '@/utils/request' import request from '@/utils/request'
import axios from 'axios'
import { getToken } from '@/utils/auth'
// JD订单列表
export function listJDOrders(query) {
return request({
url: '/system/jdorder/list',
method: 'get',
params: query
})
}
// JD订单详情
export function getJDOrder(id) {
return request({
url: `/system/jdorder/${id}`,
method: 'get'
})
}
// 更新JD订单
export function updateJDOrder(data) {
return request({
url: '/system/jdorder',
method: 'put',
data: data
})
}
// 一键转链 // 一键转链
export function generatePromotionContent(data) { export function generatePromotionContent(data) {
@@ -8,3 +36,241 @@ export function generatePromotionContent(data) {
data: data data: data
}) })
} }
// 创建商品(基于转链生成的文案与图片)
export function createProductByPromotion(data) {
return request({
url: '/erp/product/createByPromotion',
method: 'post',
data: data
})
}
// 上架商品
export function publishProduct(data) {
return request({
url: '/erp/product/publish',
method: 'post',
data
})
}
// 地区下拉
export function getProvinces() {
return request({
url: '/erp/region/provinces',
method: 'get'
})
}
export function getCities(provId) {
return request({
url: '/erp/region/cities',
method: 'get',
params: { provId }
})
}
export function getAreas(provId, cityId) {
return request({
url: '/erp/region/areas',
method: 'get',
params: { provId, cityId }
})
}
// 类目下拉
export function getCategories(params) {
return request({
url: '/erp/product/categories',
method: 'get',
params
})
}
// 会员名下拉
export function getUsernames(params) {
return request({
url: '/erp/product/usernames',
method: 'get',
params
})
}
// ERP 账号下拉(备用)
export function getERPAccounts() {
return request({
url: '/erp/product/ERPAccount',
method: 'get'
})
}
// 属性下拉
export function getProperties(params) {
return request({
url: '/erp/product/pv',
method: 'get',
params
})
}
// 开礼金
export function createGiftCoupon(data) {
return request({
url: '/jarvis/jdorder/createGiftCoupon',
method: 'post',
data
})
}
// 转链(支持礼金)
export function transferWithGift(data) {
return request({
url: '/jarvis/jdorder/transfer',
method: 'post',
data
})
}
// 批量创建礼金券
export function batchCreateGiftCoupons(data) {
return request({
url: '/jarvis/jdorder/batchCreateGiftCoupons',
method: 'post',
data
})
}
// 文本URL替换批量创建礼金并替换
export function replaceUrlsWithGiftCoupons(data) {
return request({
url: '/jarvis/jdorder/replaceUrlsWithGiftCoupons',
method: 'post',
data
})
}
// 导出JD订单列表
export function exportJDOrders(query) {
return request({
url: '/system/jdorder/export',
method: 'post',
params: query
})
}
// 删除JD订单支持批量ids为逗号分隔或数组
export function delJDOrder(ids) {
// 兼容数组或字符串
const idPath = Array.isArray(ids) ? ids.join(',') : ids
return request({
url: `/system/jdorder/${idPath}`,
method: 'delete'
})
}
// 后返表上传记录列表
export function listGroupRebateExcelUploads(query) {
return request({
url: '/system/jdorder/groupRebateUpload/list',
method: 'get',
params: query
})
}
/** 删除后返表上传记录,并回滚对应订单后返备注(新导入数据) */
export function deleteGroupRebateUpload(id) {
return request({
url: '/system/jdorder/groupRebateUpload/' + id,
method: 'delete'
})
}
function postGroupRebateMultipart(url, formData) {
return axios
.post(process.env.VUE_APP_BASE_API + url, formData, {
headers: { Authorization: 'Bearer ' + getToken() },
transformRequest: [
(data, headers) => {
if (data instanceof FormData) {
if (headers && typeof headers.delete === 'function') {
headers.delete('Content-Type')
} else if (headers) {
delete headers['Content-Type']
}
}
return data
}
]
})
.then((res) => {
const d = res.data
if (!d || d.code !== 200) {
return Promise.reject(new Error((d && d.msg) || '请求失败'))
}
return d
})
}
/** 导入跟团返现 Excelmultipart单文件 */
export function importGroupRebateExcel(formData) {
return postGroupRebateMultipart('/system/jdorder/importGroupRebateExcel', formData)
}
/** 批量导入后返表multipart多个 files 字段) */
export function importGroupRebateExcelBatch(formData) {
return postGroupRebateMultipart('/system/jdorder/importGroupRebateExcelBatch', formData)
}
// 手动获取物流信息(用于调试)
export function fetchLogisticsManually(data) {
return request({
url: '/jarvis/jdorder/fetchLogisticsManually',
method: 'post',
data
})
}
// 订单搜索工具接口(返回简易字段)
export function searchOrders(query) {
return request({
url: '/system/jdorder/tools/search',
method: 'get',
params: query
})
}
// 批量标记后返到账(赔付金额>0的订单
export function batchMarkRebateReceived() {
return request({
url: '/system/jdorder/tools/batch-mark-rebate-received',
method: 'post'
})
}
/** 按订单 id 批量重算售价(从型号配置回填)与利润 */
export function recalcProfitBatch(ids) {
return request({
url: '/system/jdorder/tools/recalc-profit',
method: 'post',
data: { ids }
})
}
/** 本页订单:利润未手动的按规则重算,仅变化时落库(刷新列表后调用) */
export function syncAutoProfitBatch(ids) {
return request({
url: '/system/jdorder/tools/sync-auto-profit',
method: 'post',
data: { ids }
})
}
// 生成录单格式文本Excel可粘贴格式
export function generateExcelText(query) {
return request({
url: '/system/jdorder/generateExcelText',
method: 'get',
params: query
})
}

View File

@@ -0,0 +1,60 @@
import request from '@/utils/request'
// 查询商品错误提示列表
export function listPrdErrorTip(query) {
return request({
url: '/jarvis/prdErrorTip/list',
method: 'get',
params: query
})
}
// 查询商品错误提示详细
export function getPrdErrorTip(id) {
return request({
url: '/jarvis/prdErrorTip/' + id,
method: 'get'
})
}
// 新增商品错误提示
export function addPrdErrorTip(data) {
return request({
url: '/jarvis/prdErrorTip',
method: 'post',
data: data
})
}
// 修改商品错误提示
export function updatePrdErrorTip(data) {
return request({
url: '/jarvis/prdErrorTip',
method: 'put',
data: data
})
}
// 删除商品错误提示
export function delPrdErrorTip(id) {
return request({
url: '/jarvis/prdErrorTip/' + id,
method: 'delete'
})
}
// 批量删除商品错误提示
export function delPrdErrorTips(ids) {
return request({
url: '/jarvis/prdErrorTip/' + ids,
method: 'delete'
})
}
// 根据错误代码和子代码查询错误提示
export function getPrdErrorTipByCode(errCode, errSubCode) {
return request({
url: '/jarvis/prdErrorTip/getByCode/' + errCode + '/' + errSubCode,
method: 'get'
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 KiB

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -89,4 +89,448 @@
> .el-submenu__title > .el-submenu__title
.el-submenu__icon-arrow { .el-submenu__icon-arrow {
display: none; display: none;
}
// 移动端 Element UI 组件优化
@media (max-width: 768px) {
// 表格优化
.el-table {
font-size: 12px;
.el-table__header-wrapper,
.el-table__body-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
th,
td {
padding: 8px 5px !important;
font-size: 12px;
}
.el-table__cell {
padding: 8px 5px !important;
}
// 操作列按钮优化
.el-button {
padding: 5px 8px;
font-size: 12px;
margin: 2px;
&.el-button--mini {
padding: 4px 6px;
font-size: 11px;
}
}
}
// 表格工具栏优化
.el-table__header-wrapper {
.el-table__header {
th {
font-size: 12px;
font-weight: 600;
}
}
}
// 表单优化
.el-form {
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
font-size: 14px;
line-height: 1.5;
padding-bottom: 5px;
width: 100% !important;
text-align: left !important;
}
.el-form-item__content {
margin-left: 0 !important;
}
// 表单项内联优化
.el-form-item--mini,
.el-form-item--small {
.el-form-item__label {
font-size: 13px;
}
}
}
// 输入框优化
.el-input {
.el-input__inner {
font-size: 16px; // 防止iOS自动缩放
height: 44px; // 增大触摸目标
line-height: 44px;
}
}
// 选择器优化
.el-select {
width: 100%;
.el-input__inner {
font-size: 16px;
height: 44px;
line-height: 44px;
}
}
// 日期选择器优化
.el-date-editor {
width: 100% !important;
.el-input__inner {
font-size: 16px;
height: 44px;
line-height: 44px;
}
}
// 按钮优化
.el-button {
min-height: 44px; // 增大触摸目标
padding: 10px 15px;
font-size: 14px;
&.el-button--mini {
min-height: 36px;
padding: 6px 10px;
font-size: 12px;
}
&.el-button--small {
min-height: 40px;
padding: 8px 12px;
font-size: 13px;
}
}
// 对话框优化
.el-dialog {
width: 95% !important;
margin: 5vh auto !important;
border-radius: 8px;
.el-dialog__header {
padding: 15px;
font-size: 16px;
}
.el-dialog__body {
padding: 15px;
max-height: calc(90vh - 120px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.el-dialog__footer {
padding: 10px 15px;
.el-button {
width: 100%;
margin: 5px 0;
}
}
}
// 消息框优化
.el-message-box {
width: 90% !important;
.el-message-box__content {
padding: 15px;
}
.el-message-box__btns {
.el-button {
width: 48%;
margin: 0 1%;
}
}
}
// 抽屉优化
.el-drawer {
width: 85% !important;
.el-drawer__header {
padding: 15px;
font-size: 16px;
}
.el-drawer__body {
padding: 15px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
// 分页优化
.el-pagination {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 5px;
padding: 10px 0;
.el-pagination__sizes,
.el-pagination__total,
.el-pagination__jump {
display: none;
}
.btn-prev,
.btn-next,
.el-pager li {
min-width: 36px;
height: 36px;
line-height: 36px;
font-size: 13px;
}
}
// 标签页优化
.el-tabs {
.el-tabs__header {
margin: 0 0 15px 0;
}
.el-tabs__nav-wrap {
&::after {
height: 1px;
}
}
.el-tabs__item {
padding: 0 12px;
font-size: 14px;
height: 44px;
line-height: 44px;
}
.el-tabs__content {
padding: 10px 0;
}
}
// 卡片优化
.el-card {
margin-bottom: 10px;
border-radius: 8px;
.el-card__header {
padding: 12px 15px;
font-size: 14px;
font-weight: 600;
}
.el-card__body {
padding: 15px;
}
}
// 步骤条优化
.el-steps {
.el-step__title {
font-size: 12px;
}
.el-step__description {
font-size: 11px;
}
}
// 上传组件优化
.el-upload {
width: 100%;
.el-upload-dragger {
width: 100%;
padding: 20px;
}
}
// 标签优化
.el-tag {
font-size: 12px;
padding: 4px 8px;
margin: 2px;
}
// 开关优化
.el-switch {
.el-switch__core {
min-width: 44px;
height: 24px;
}
}
// 单选框组优化
.el-radio-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
.el-radio {
margin-right: 0;
margin-bottom: 10px;
}
}
// 复选框组优化
.el-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
.el-checkbox {
margin-right: 0;
margin-bottom: 10px;
}
}
// 级联选择器优化
.el-cascader {
width: 100%;
.el-input__inner {
font-size: 16px;
height: 44px;
line-height: 44px;
}
}
// 时间选择器优化
.el-time-picker {
width: 100%;
.el-input__inner {
font-size: 16px;
height: 44px;
line-height: 44px;
}
}
// 数字输入框优化
.el-input-number {
width: 100%;
.el-input__inner {
font-size: 16px;
height: 44px;
line-height: 44px;
}
}
// 滑块优化
.el-slider {
margin: 15px 0;
.el-slider__button {
width: 20px;
height: 20px;
}
}
// 评分优化
.el-rate {
.el-rate__item {
font-size: 20px;
}
}
// 颜色选择器优化
.el-color-picker {
.el-color-picker__trigger {
width: 44px;
height: 44px;
}
}
// 穿梭框优化
.el-transfer {
.el-transfer-panel {
width: 45%;
}
}
// 树形控件优化
.el-tree {
.el-tree-node__content {
height: 40px;
line-height: 40px;
}
}
// 折叠面板优化
.el-collapse {
.el-collapse-item__header {
font-size: 14px;
height: 44px;
line-height: 44px;
padding: 0 15px;
}
.el-collapse-item__content {
padding: 15px;
font-size: 13px;
}
}
// 时间线优化
.el-timeline {
.el-timeline-item__content {
font-size: 13px;
}
}
// 描述列表优化
.el-descriptions {
.el-descriptions__label {
font-size: 13px;
width: 30%;
}
.el-descriptions__content {
font-size: 13px;
}
}
// 空状态优化
.el-empty {
padding: 20px;
.el-empty__description {
font-size: 13px;
}
}
// 骨架屏优化
.el-skeleton {
.el-skeleton__item {
margin-bottom: 10px;
}
}
// 结果页优化
.el-result {
padding: 20px;
.el-result__title {
font-size: 16px;
}
.el-result__subtitle {
font-size: 13px;
}
}
} }

View File

@@ -4,6 +4,7 @@
@import './element-ui.scss'; @import './element-ui.scss';
@import './sidebar.scss'; @import './sidebar.scss';
@import './btn.scss'; @import './btn.scss';
@import './mobile.scss';
body { body {
height: 100%; height: 100%;
@@ -122,6 +123,10 @@ aside {
//main-container全局样式 //main-container全局样式
.app-container { .app-container {
padding: 20px; padding: 20px;
@media (max-width: 768px) {
padding: 10px;
}
} }
.components-container { .components-container {
@@ -176,3 +181,214 @@ aside {
margin-bottom: 10px; margin-bottom: 10px;
} }
} }
// 移动端响应式优化
@media (max-width: 768px) {
// 全局容器优化
.app-container {
padding: 10px !important;
}
.components-container {
margin: 15px 10px !important;
}
// 表单优化
.el-form {
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
font-size: 14px;
padding-bottom: 5px;
}
.el-input,
.el-select,
.el-date-picker {
width: 100% !important;
}
}
// 按钮组优化
.el-button-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
.el-button {
flex: 1;
min-width: 80px;
}
}
// 对话框优化
.el-dialog {
width: 95% !important;
margin: 5vh auto !important;
max-height: 90vh;
overflow-y: auto;
.el-dialog__body {
padding: 15px;
max-height: calc(90vh - 120px);
overflow-y: auto;
}
}
// 抽屉优化
.el-drawer {
width: 85% !important;
}
// 分页优化
.pagination-container {
padding: 10px 0;
.el-pagination {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 5px;
.el-pagination__sizes,
.el-pagination__total,
.el-pagination__jump {
display: none; // 移动端隐藏部分分页信息
}
}
}
// 卡片优化
.el-card {
margin-bottom: 10px;
.el-card__header {
padding: 12px 15px;
font-size: 14px;
}
.el-card__body {
padding: 15px;
}
}
// 标签页优化
.el-tabs {
.el-tabs__header {
margin: 0 0 15px 0;
}
.el-tabs__item {
padding: 0 12px;
font-size: 14px;
}
}
// 表格容器优化
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
.el-table {
min-width: 600px; // 保持最小宽度,允许横向滚动
}
}
// 搜索表单优化
.search-form {
.el-form-item {
width: 100% !important;
margin-right: 0 !important;
}
}
// 操作按钮区域优化
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 15px;
.el-button {
flex: 1;
min-width: 80px;
}
}
// 文本溢出处理
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 触摸优化
* {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
// 移动端卡片列表优化
.mobile-card-list {
background: #f5f7fa;
padding: 12px;
}
// 移动端搜索表单优化
.mobile-search-form {
.search-bar {
background: #fff;
border-radius: 8px;
margin-bottom: 12px;
}
}
// 移动端按钮组优化
.mobile-button-group {
.mobile-buttons {
background: #fff;
border-radius: 8px;
margin-bottom: 12px;
}
}
// 移动端表格容器优化
.table-container {
@media (max-width: 768px) {
.el-table {
display: none; // 移动端隐藏表格,使用卡片视图
}
}
}
// 输入框优化防止iOS自动缩放
input[type="text"],
input[type="password"],
input[type="number"],
input[type="email"],
input[type="tel"],
textarea,
select {
font-size: 16px !important;
}
// 禁用文本选择(移动端长按优化)
.no-select {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}
// 平板设备优化
@media (min-width: 769px) and (max-width: 1024px) {
.app-container {
padding: 15px !important;
}
.components-container {
margin: 20px 30px !important;
}
}

View File

@@ -0,0 +1,394 @@
/**
* 移动端专用样式
*/
// 移动端全局优化
@media (max-width: 768px) {
// 页面容器
.app-container {
padding: 12px !important;
background: #f5f7fa;
min-height: calc(100vh - 48px - 60px); // 减去头部和底部导航
}
// 卡片样式
.el-card {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 12px;
border: none;
.el-card__header {
padding: 16px;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid #f0f0f0;
}
.el-card__body {
padding: 16px;
}
}
// 列表项优化
.el-list-item {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
// 分割线优化
.el-divider {
margin: 16px 0;
}
// 标签优化
.el-tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
margin: 2px;
}
// 徽章优化
.el-badge {
.el-badge__content {
font-size: 10px;
padding: 0 4px;
height: 16px;
line-height: 16px;
min-width: 16px;
}
}
// 步骤条优化
.el-steps {
.el-step__title {
font-size: 12px;
}
.el-step__description {
font-size: 11px;
}
}
// 时间线优化
.el-timeline {
padding-left: 20px;
.el-timeline-item__node {
width: 12px;
height: 12px;
}
.el-timeline-item__content {
font-size: 13px;
}
}
// 描述列表优化
.el-descriptions {
.el-descriptions__label {
width: 35%;
font-size: 13px;
color: #909399;
}
.el-descriptions__content {
font-size: 13px;
color: #303133;
}
}
// 空状态优化
.el-empty {
padding: 40px 20px;
.el-empty__image {
width: 120px;
height: 120px;
}
.el-empty__description {
font-size: 14px;
color: #909399;
margin-top: 16px;
}
}
// 骨架屏优化
.el-skeleton {
padding: 16px;
.el-skeleton__item {
margin-bottom: 12px;
border-radius: 4px;
}
}
// 结果页优化
.el-result {
padding: 30px 20px;
.el-result__icon {
font-size: 60px;
}
.el-result__title {
font-size: 18px;
margin-top: 20px;
}
.el-result__subtitle {
font-size: 14px;
margin-top: 12px;
}
}
// 加载优化
.el-loading-mask {
border-radius: 8px;
}
// 消息提示优化
.el-message {
min-width: auto;
width: 90%;
left: 50%;
transform: translateX(-50%);
border-radius: 8px;
padding: 12px 16px;
.el-message__content {
font-size: 14px;
}
}
// 通知优化
.el-notification {
width: 90%;
border-radius: 8px;
padding: 16px;
.el-notification__title {
font-size: 15px;
}
.el-notification__content {
font-size: 13px;
margin-top: 8px;
}
}
// 弹出层优化
.el-popover {
border-radius: 8px;
padding: 12px;
max-width: 90vw;
}
// 工具提示优化
.el-tooltip__popper {
font-size: 12px;
padding: 8px 12px;
border-radius: 6px;
}
// 下拉菜单优化
.el-dropdown-menu {
border-radius: 8px;
padding: 8px 0;
.el-dropdown-menu__item {
padding: 12px 20px;
font-size: 14px;
&:hover {
background: #f5f7fa;
}
}
}
// 选择器下拉优化
.el-select-dropdown {
border-radius: 8px;
.el-select-dropdown__item {
padding: 12px 20px;
font-size: 14px;
}
}
// 日期选择器优化
.el-picker-panel {
width: 95%;
left: 2.5% !important;
border-radius: 8px;
.el-date-picker__header {
padding: 12px 16px;
}
.el-picker-panel__content {
padding: 8px;
}
}
// 级联选择器优化
.el-cascader-menus {
border-radius: 8px;
.el-cascader-menu {
min-width: 120px;
}
}
// 穿梭框优化
.el-transfer {
display: flex;
flex-direction: column;
gap: 16px;
.el-transfer-panel {
width: 100%;
border-radius: 8px;
}
}
// 树形控件优化
.el-tree {
.el-tree-node__content {
height: 44px;
padding: 0 12px;
&:hover {
background: #f5f7fa;
}
}
.el-tree-node__label {
font-size: 14px;
}
}
// 折叠面板优化
.el-collapse {
border: none;
.el-collapse-item {
margin-bottom: 12px;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
.el-collapse-item__header {
padding: 16px;
font-size: 15px;
font-weight: 500;
}
.el-collapse-item__content {
padding: 16px;
font-size: 14px;
}
}
}
// 进度条优化
.el-progress {
.el-progress__text {
font-size: 12px;
}
}
// 滑块优化
.el-slider {
margin: 20px 0;
.el-slider__button {
width: 20px;
height: 20px;
border: 2px solid #409eff;
}
}
// 评分优化
.el-rate {
.el-rate__item {
font-size: 24px;
margin-right: 8px;
}
}
// 颜色选择器优化
.el-color-picker {
.el-color-picker__trigger {
width: 50px;
height: 50px;
border-radius: 8px;
}
}
// 上传组件优化
.el-upload {
width: 100%;
.el-upload-dragger {
width: 100%;
padding: 30px;
border-radius: 8px;
}
}
// 图片预览优化
.el-image-viewer__wrapper {
.el-image-viewer__canvas {
img {
max-width: 90vw;
max-height: 90vh;
}
}
}
// 抽屉优化
.el-drawer {
.el-drawer__header {
padding: 20px;
border-bottom: 1px solid #e4e7ed;
.el-drawer__title {
font-size: 18px;
font-weight: 600;
}
}
.el-drawer__body {
padding: 20px;
}
}
// 固定定位元素优化(适配安全区域)
.fixed-bottom {
bottom: env(safe-area-inset-bottom);
}
.fixed-top {
top: env(safe-area-inset-top);
}
}
// 平板设备优化
@media (min-width: 769px) and (max-width: 1024px) {
.app-container {
padding: 16px !important;
}
.el-card {
border-radius: 10px;
margin-bottom: 16px;
}
}

View File

@@ -137,6 +137,68 @@
.pagination-container .el-pagination > .el-pagination__sizes { .pagination-container .el-pagination > .el-pagination__sizes {
display: none !important; display: none !important;
} }
// 表格移动端优化
.el-table {
font-size: 12px;
.el-table__header-wrapper,
.el-table__body-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
th, td {
padding: 8px 5px !important;
font-size: 12px;
}
.el-table__cell {
padding: 8px 5px !important;
}
}
// 表单移动端优化
.form-header {
font-size: 14px;
margin: 5px 5px 15px 5px;
padding-bottom: 5px;
}
// 卡片移动端优化
.el-card__header {
padding: 10px 12px 5px !important;
font-size: 14px;
}
.el-card__body {
padding: 12px 15px 15px 15px !important;
}
// 按钮组移动端优化
.top-right-btn {
float: none;
margin-bottom: 10px;
width: 100%;
.el-button {
width: 100%;
margin-bottom: 8px;
}
}
// 工具类移动端优化
.mb20, .mt20, .mr20, .ml20 {
margin: 10px !important;
}
.mb10, .mt10, .mr10, .ml10 {
margin: 8px !important;
}
.mb5, .mt5, .mr5, .ml5 {
margin: 5px !important;
}
} }
.el-table .fixed-width .el-button--mini { .el-table .fixed-width .el-button--mini {

View File

@@ -12,20 +12,42 @@
} }
.sidebar-container { .sidebar-container {
-webkit-transition: width .28s; -webkit-transition: width .28s ease-in-out;
transition: width 0.28s; transition: width 0.28s ease-in-out, box-shadow 0.3s ease;
width: $base-sidebar-width !important; width: $base-sidebar-width !important;
background-color: $base-menu-background; background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
height: 100%; height: 100vh;
position: fixed; position: fixed;
font-size: 0px;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: 1001; z-index: 1001;
overflow: hidden; overflow: hidden;
-webkit-box-shadow: 2px 0 6px rgba(0,21,41,.35); border-radius: 0 20px 20px 0;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); -webkit-box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
// 添加悬停效果
&:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
// 添加进入动画
animation: slideInLeft 0.5s ease-out;
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
// reset element-ui css // reset element-ui css
.horizontal-collapse-transition { .horizontal-collapse-transition {
@@ -46,7 +68,7 @@
&.has-logo { &.has-logo {
.el-scrollbar { .el-scrollbar {
height: calc(100% - 50px); height: calc(100% - 60px);
} }
} }
@@ -68,41 +90,132 @@
border: none; border: none;
height: 100%; height: 100%;
width: 100% !important; width: 100% !important;
font-size: 14px;
background: transparent !important;
padding: 10px 0;
} }
.el-menu-item, .el-submenu__title { .el-menu-item, .el-submenu__title {
overflow: hidden !important; overflow: hidden !important;
text-overflow: ellipsis !important; text-overflow: ellipsis !important;
white-space: nowrap !important; white-space: nowrap !important;
} font-size: 14px !important;
font-weight: 500;
// menu hover color: rgba(33, 33, 33, 0.85) !important;
.submenu-title-noDropdown, background: transparent !important;
.el-submenu__title { border-radius: 12px;
margin: 4px 12px;
padding: 0 16px !important;
height: 48px;
line-height: 48px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
span {
font-size: 14px !important;
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
}
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.06) !important; background: rgba(25, 118, 210, 0.1) !important;
color: #1976d2 !important;
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.is-active {
background: rgba(25, 118, 210, 0.15) !important;
color: #1976d2 !important;
font-weight: 600;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.2);
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 20px;
background: #1976d2;
border-radius: 0 2px 2px 0;
}
} }
} }
& .theme-dark .is-active > .el-submenu__title { // 子菜单样式优化
color: $base-menu-color-active !important; .el-submenu {
} .el-submenu__title {
border-radius: 12px;
& .nest-menu .el-submenu>.el-submenu__title, margin: 4px 12px;
& .el-submenu .el-menu-item { padding: 0 16px !important;
min-width: $base-sidebar-width !important; height: 48px;
line-height: 48px;
&:hover {
background-color: rgba(0, 0, 0, 0.06) !important; &:hover {
background: rgba(25, 118, 210, 0.1) !important;
color: #1976d2 !important;
transform: translateX(4px);
}
}
.el-menu {
background: rgba(255, 255, 255, 0.3) !important;
border-radius: 12px;
margin: 8px 12px;
padding: 8px 0;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
.el-menu-item {
margin: 2px 8px;
padding: 0 24px !important;
height: 40px;
line-height: 40px;
font-size: 13px !important;
span {
font-size: 13px !important;
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
}
&:hover {
background: rgba(25, 118, 210, 0.08) !important;
color: #1976d2 !important;
transform: translateX(2px);
}
&.is-active {
background: rgba(25, 118, 210, 0.12) !important;
color: #1976d2 !important;
&::before {
width: 3px;
height: 16px;
}
}
}
} }
} }
& .theme-dark .nest-menu .el-submenu>.el-submenu__title, // 图标样式优化
& .theme-dark .el-submenu .el-menu-item { .svg-icon {
background-color: $base-sub-menu-background !important; color: rgba(33, 33, 33, 0.7) !important;
transition: all 0.3s ease;
margin-right: 12px;
&:hover { &:hover {
background-color: $base-sub-menu-hover !important; color: #1976d2 !important;
transform: scale(1.1);
} }
} }
} }
@@ -127,6 +240,14 @@
margin-left: 20px; margin-left: 20px;
} }
} }
span {
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
} }
.el-submenu { .el-submenu {
@@ -138,7 +259,14 @@
.svg-icon { .svg-icon {
margin-left: 20px; margin-left: 20px;
} }
span {
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
} }
} }
@@ -146,14 +274,24 @@
.el-submenu { .el-submenu {
&>.el-submenu__title { &>.el-submenu__title {
&>span { &>span {
height: 0; height: 0 !important;
width: 0; width: 0 !important;
overflow: hidden; overflow: hidden !important;
visibility: hidden; visibility: hidden !important;
display: inline-block; display: inline-block !important;
} }
} }
} }
.el-menu-item {
span {
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
}
} }
} }
@@ -168,8 +306,24 @@
} }
.sidebar-container { .sidebar-container {
transition: transform .28s; transition: transform .28s ease-in-out;
width: $base-sidebar-width !important; width: $base-sidebar-width !important;
border-radius: 0;
// 移动端优化
.el-menu-item, .el-submenu__title {
margin: 2px 8px;
padding: 0 12px !important;
height: 44px;
line-height: 44px;
font-size: 13px;
}
.el-submenu .el-menu-item {
height: 36px;
line-height: 36px;
font-size: 12px;
}
} }
&.hideSidebar { &.hideSidebar {
@@ -177,6 +331,38 @@
pointer-events: none; pointer-events: none;
transition-duration: 0.3s; transition-duration: 0.3s;
transform: translate3d(-$base-sidebar-width, 0, 0); transform: translate3d(-$base-sidebar-width, 0, 0);
box-shadow: none;
}
}
}
// 平板设备优化
@media (max-width: 1024px) {
.sidebar-container {
.el-menu-item, .el-submenu__title {
font-size: 13px;
height: 44px;
line-height: 44px;
}
}
}
// 小屏幕优化
@media (max-width: 768px) {
.sidebar-container {
border-radius: 0;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
.el-menu {
padding: 8px 0;
}
.el-menu-item, .el-submenu__title {
margin: 2px 6px;
padding: 0 10px !important;
height: 40px;
line-height: 40px;
font-size: 12px;
} }
} }
} }
@@ -188,9 +374,55 @@
transition: none; transition: none;
} }
} }
// 弹出菜单样式优化
.el-menu--popup {
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%) !important;
border-radius: 12px !important;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 0;
.el-menu-item {
color: rgba(33, 33, 33, 0.85) !important;
margin: 2px 8px;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
background: rgba(25, 118, 210, 0.1) !important;
color: #1976d2 !important;
transform: translateX(4px);
}
&.is-active {
background: rgba(25, 118, 210, 0.15) !important;
color: #1976d2 !important;
}
}
// 自定义滚动条
&::-webkit-scrollbar-track-piece {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
} }
// when menu collapsed // 收起状态下的样式
.el-menu--vertical { .el-menu--vertical {
&>.el-menu { &>.el-menu {
.svg-icon { .svg-icon {
@@ -201,27 +433,7 @@
.nest-menu .el-submenu>.el-submenu__title, .nest-menu .el-submenu>.el-submenu__title,
.el-menu-item { .el-menu-item {
&:hover { &:hover {
// you can use $subMenuHover background: rgba(255, 255, 255, 0.15) !important;
background-color: rgba(0, 0, 0, 0.06) !important;
}
}
// the scroll bar appears when the subMenu is too long
>.el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
} }
} }
} }

View File

@@ -8,18 +8,18 @@ $tiffany: #4AB7BD;
$yellow:#FEC171; $yellow:#FEC171;
$panGreen: #30B08F; $panGreen: #30B08F;
// 默认菜单主题风格 // 默认菜单主题风格 - 现代化渐变主题
$base-menu-color:#bfcbd9; $base-menu-color:rgba(33, 33, 33, 0.85);
$base-menu-color-active:#f4f4f5; $base-menu-color-active:#1976d2;
$base-menu-background:#304156; $base-menu-background:linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
$base-logo-title-color: #ffffff; $base-logo-title-color: #1976d2;
$base-menu-light-color:rgba(0,0,0,.70); $base-menu-light-color:rgba(0,0,0,.70);
$base-menu-light-background:#ffffff; $base-menu-light-background:#ffffff;
$base-logo-light-title-color: #001529; $base-logo-light-title-color: #001529;
$base-sub-menu-background:#1f2d3d; $base-sub-menu-background:rgba(255,255,255,0.08);
$base-sub-menu-hover:#001528; $base-sub-menu-hover:rgba(255,255,255,0.15);
// 自定义暗色菜单风格 // 自定义暗色菜单风格
/** /**

View File

@@ -0,0 +1,164 @@
<template>
<div class="list-layout">
<!-- 顶部搜索区域 -->
<div class="search-section">
<slot name="search"></slot>
</div>
<!-- 表格区域 - 可滚动 -->
<div class="table-section">
<slot name="table"></slot>
</div>
<!-- 固定分页区域 -->
<div class="pagination-section">
<slot name="pagination"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListLayout',
props: {
// 可以添加一些配置属性
height: {
type: String,
default: 'calc(100vh - 84px)'
}
}
}
</script>
<style scoped>
/* 主容器布局 */
.list-layout {
display: flex;
flex-direction: column;
height: calc(100vh - 84px); /* 减去头部导航高度 */
overflow: hidden;
}
/* 搜索区域 - 固定在顶部;限制高度避免矮窗口下占满视口、表格不可见 */
.search-section {
flex-shrink: 0;
background: #fff;
padding: 20px;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-height: min(520px, calc(100vh - 84px - 200px));
overflow-y: auto;
overflow-x: hidden;
}
/* 表格区域 - 可滚动 */
.table-section {
flex: 1;
overflow: auto;
padding: 0 20px;
background: #fff;
min-height: 0; /* 确保 flex 子元素可以收缩 */
overflow-x: auto; /* 确保横向滚动条显示 */
overflow-y: auto; /* 确保纵向滚动条显示 */
}
/* 固定分页区域 */
.pagination-section {
flex-shrink: 0;
background: #fff;
padding: 15px 20px;
border-top: 1px solid #e4e7ed;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
position: sticky;
bottom: 0;
z-index: 10;
}
.search-section::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.search-section::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.search-section::-webkit-scrollbar-thumb {
background: #c0c0c0;
border-radius: 3px;
}
.search-section::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 表格区域滚动条样式 */
.table-section::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.table-section::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.table-section::-webkit-scrollbar-thumb {
background: #c0c0c0;
border-radius: 3px;
}
.table-section::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 确保表格在容器内正确显示,允许横向滚动 */
.table-section .el-table {
min-width: 100%;
}
/* 分页组件样式优化 */
.pagination-section .pagination-container {
display: flex;
justify-content: center;
align-items: center;
background: #fff;
border-radius: 4px;
padding: 10px 0;
}
/* 窗口高度较矮时:为表格多留垂直空间、收紧搜索区内边距 */
@media (max-height: 720px) {
.list-layout .search-section {
padding: 12px 16px;
max-height: min(420px, calc(100vh - 84px - 160px));
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.list-layout {
height: calc(100vh - 48px - 60px); /* 移动端调整高度:减去头部和底部导航 */
}
.search-section {
padding: 12px;
max-height: 50vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.table-section {
padding: 0 10px;
flex: 1;
min-height: 0;
}
.pagination-section {
padding: 8px 10px;
/* 移动端隐藏分页,因为很少用 */
display: none;
}
}
</style>

View File

@@ -0,0 +1,253 @@
<template>
<div class="mobile-bottom-nav" :class="{ 'scrollable': navItems.length > 5 }" v-if="isMobile && show">
<div
v-for="item in navItems"
:key="item.path || item.label || item.icon"
class="nav-item"
:class="{ 'active': isActive(item.path) }"
@click="handleNavClick(item)"
>
<div class="nav-icon">
<i :class="item.icon" v-if="item.icon"></i>
<svg-icon :icon-class="item.iconClass" v-else-if="item.iconClass" />
<el-badge :value="item.badge" :hidden="!item.badge" v-if="item.badge">
<div class="icon-placeholder"></div>
</el-badge>
</div>
<div class="nav-label">{{ item.label }}</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'MobileBottomNav',
props: {
items: {
type: Array,
default: () => []
},
show: {
type: Boolean,
default: true
}
},
computed: {
...mapGetters(['device', 'sidebarRouters']),
isMobile() {
return this.device === 'mobile' || window.innerWidth < 768
},
navItems() {
// 如果提供了自定义 items直接使用
if (this.items && this.items.length > 0) {
return this.items
}
// 始终从 store 的 sidebarRouters 计算,保证与接口返回、路由注册一致,避免移动端菜单/跳转错乱
const routes = this.sidebarRouters || []
const flatRoutes = this.flattenRoutes(routes)
const mainRoutes = flatRoutes.filter(route => {
const excludePaths = ['/redirect', '/login', '/register', '/404', '/401', '/user/profile']
const path = route.path || ''
return path &&
path !== '/' &&
!excludePaths.some(exclude => path.includes(exclude)) &&
!path.startsWith('/user/')
})
if (mainRoutes.length > 0) {
return mainRoutes
}
return [
{ path: '/sloworder/index', label: '首页', icon: 'el-icon-s-home' }
]
}
},
methods: {
/** 扁平化路由为叶子节点,路径与 Vue Router 注册的完整 path 一致 */
flattenRoutes(routes, parentPath = '') {
if (!routes || !Array.isArray(routes)) return []
const result = []
routes.forEach(route => {
if (route.hidden) return
let fullPath = (route.path || '').trim()
if (parentPath) {
if (fullPath.startsWith('/')) {
// 已是绝对路径,直接使用
} else {
const base = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath
fullPath = `${base}/${fullPath}`.replace(/\/+/g, '/')
}
}
if (fullPath && !fullPath.startsWith('/')) {
fullPath = '/' + fullPath
}
if (route.children && route.children.length > 0) {
result.push(...this.flattenRoutes(route.children, fullPath))
} else if (route.meta && route.meta.title && fullPath) {
result.push({
path: fullPath,
label: route.meta.title,
icon: route.meta.icon || 'el-icon-menu',
iconClass: route.meta.icon,
route
})
}
})
return result
},
isActive(path) {
if (!path) return false
const currentPath = this.$route.path
return currentPath === path || currentPath.startsWith(path + '/')
},
handleNavClick(item) {
if (item.handler) {
item.handler()
this.$emit('nav-click', item)
return
}
if (item.path) {
// 确保路径正确
let path = item.path
if (!path.startsWith('/')) {
path = `/${path}`
}
// 移除末尾的斜杠(除了根路径)
if (path !== '/' && path.endsWith('/')) {
path = path.slice(0, -1)
}
// 尝试导航
this.$router.push(path).catch(err => {
// 如果push失败尝试replace
if (err.name !== 'NavigationDuplicated') {
this.$router.replace(path).catch(() => {
console.error('Navigation to', path, 'failed')
// 不显示错误消息,避免打扰用户
})
}
})
}
this.$emit('nav-click', item)
}
}
}
</script>
<style lang="scss" scoped>
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: #fff;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
padding-bottom: env(safe-area-inset-bottom);
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
padding: 0 4px;
// 隐藏滚动条但保持滚动功能
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
&.scrollable {
justify-content: flex-start;
}
.nav-item {
flex: 0 0 auto;
min-width: 70px;
max-width: 90px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 4px;
cursor: pointer;
transition: all 0.3s;
-webkit-tap-highlight-color: transparent;
.nav-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
position: relative;
i, .svg-icon {
font-size: 22px;
color: #909399;
transition: all 0.3s;
}
.icon-placeholder {
width: 22px;
height: 22px;
}
}
.nav-label {
font-size: 11px;
color: #909399;
transition: all 0.3s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
line-height: 1.2;
}
&.active {
.nav-icon {
i, .svg-icon {
color: #409eff;
font-size: 24px;
}
}
.nav-label {
color: #409eff;
font-weight: 600;
}
}
&:active {
transform: scale(0.95);
opacity: 0.8;
}
}
}
// 桌面端隐藏
@media (min-width: 769px) {
.mobile-bottom-nav {
display: none;
}
}
// 为底部导航预留空间
@media (max-width: 768px) {
.app-main {
padding-bottom: 60px !important;
}
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<div class="mobile-button-group" :class="{ 'sticky': sticky }">
<!-- 移动端按钮组 -->
<div v-if="isMobile" class="mobile-buttons">
<!-- 主要操作按钮 -->
<div class="primary-actions" v-if="primaryButtons.length > 0">
<el-button
v-for="btn in primaryButtons"
:key="btn.key || btn.label"
:type="btn.type || 'primary'"
:size="btn.size || 'medium'"
:icon="btn.icon"
:disabled="btn.disabled"
:loading="btn.loading"
@click="handleClick(btn)"
class="action-btn"
>
{{ btn.label }}
</el-button>
</div>
<!-- 更多操作下拉菜单 -->
<el-dropdown
v-if="moreButtons.length > 0"
trigger="click"
placement="top-end"
@command="handleCommand"
>
<el-button
type="default"
size="medium"
icon="el-icon-more"
class="more-btn"
>
更多
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-for="btn in moreButtons"
:key="btn.key || btn.label"
:command="btn.key || btn.label"
:disabled="btn.disabled"
:divided="btn.divided"
>
<i :class="btn.icon" v-if="btn.icon"></i>
{{ btn.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<!-- 桌面端按钮组 -->
<div v-else class="desktop-buttons">
<slot>
<el-button
v-for="btn in allButtons"
:key="btn.key || btn.label"
:type="btn.type || 'default'"
:size="btn.size || 'mini'"
:icon="btn.icon"
:disabled="btn.disabled"
:loading="btn.loading"
:plain="btn.plain"
@click="handleClick(btn)"
>
{{ btn.label }}
</el-button>
</slot>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'MobileButtonGroup',
props: {
buttons: {
type: Array,
default: () => []
},
primaryCount: {
type: Number,
default: 2
},
sticky: {
type: Boolean,
default: false
}
},
computed: {
...mapGetters(['device']),
isMobile() {
return this.device === 'mobile' || window.innerWidth < 768
},
allButtons() {
return this.buttons || []
},
primaryButtons() {
return this.allButtons.slice(0, this.primaryCount).filter(btn => !btn.hide)
},
moreButtons() {
return this.allButtons.slice(this.primaryCount).filter(btn => !btn.hide)
}
},
methods: {
handleClick(btn) {
if (btn.handler) {
btn.handler()
}
this.$emit('button-click', btn)
},
handleCommand(command) {
const btn = this.moreButtons.find(b => (b.key || b.label) === command)
if (btn) {
this.handleClick(btn)
}
}
}
}
</script>
<style lang="scss" scoped>
.mobile-button-group {
width: 100%;
&.sticky {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
padding: 12px;
margin: -12px -12px 12px -12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
.mobile-buttons {
display: flex;
gap: 12px;
padding: 12px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
.primary-actions {
flex: 1;
display: flex;
gap: 12px;
.action-btn {
flex: 1;
height: 44px;
font-size: 15px;
border-radius: 8px;
}
}
.more-btn {
flex-shrink: 0;
width: 60px;
height: 44px;
padding: 0;
border-radius: 8px;
}
}
.desktop-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
// 桌面端隐藏移动端组件
@media (min-width: 769px) {
.mobile-buttons {
display: none;
}
}
// 移动端隐藏桌面端组件
@media (max-width: 768px) {
.desktop-buttons {
display: none;
}
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<div class="mobile-search-form" :class="{ 'expanded': expanded }">
<!-- 移动端折叠搜索 -->
<div v-if="isMobile" class="mobile-search-wrapper">
<!-- 搜索按钮栏 -->
<div class="search-bar" v-if="!expanded">
<el-input
v-model="quickSearch"
placeholder="快速搜索..."
clearable
@keyup.enter.native="handleQuickSearch"
@clear="handleQuickSearch"
>
<el-button slot="append" icon="el-icon-search" @click="handleQuickSearch"></el-button>
</el-input>
<el-button
type="text"
icon="el-icon-setting"
class="filter-btn"
@click="expanded = true"
>
筛选
</el-button>
</div>
<!-- 展开的搜索表单 -->
<div class="expanded-form" v-if="expanded">
<div class="form-header">
<span class="form-title">筛选条件</span>
<el-button
type="text"
icon="el-icon-close"
@click="expanded = false"
class="close-btn"
></el-button>
</div>
<div class="form-content">
<slot name="form" :expanded="expanded">
<el-form
:model="formData"
label-width="80px"
label-position="top"
>
<slot></slot>
</el-form>
</slot>
</div>
<div class="form-footer">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
</div>
<!-- 桌面端正常显示 -->
<div v-else class="desktop-search-wrapper">
<slot name="form" :expanded="false">
<el-form
:model="formData"
:inline="inline"
:label-width="labelWidth"
>
<slot></slot>
</el-form>
</slot>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'MobileSearchForm',
props: {
inline: {
type: Boolean,
default: true
},
labelWidth: {
type: String,
default: '68px'
},
model: {
type: Object,
default: () => ({})
}
},
data() {
return {
expanded: false,
quickSearch: '',
formData: {}
}
},
computed: {
...mapGetters(['device']),
isMobile() {
return this.device === 'mobile' || window.innerWidth < 768
}
},
watch: {
model: {
immediate: true,
deep: true,
handler(val) {
this.formData = { ...val }
}
}
},
methods: {
handleQuickSearch() {
this.$emit('quick-search', this.quickSearch)
},
handleSearch() {
this.$emit('search', this.formData)
this.expanded = false
},
handleReset() {
this.$emit('reset')
this.formData = {}
this.quickSearch = ''
}
}
}
</script>
<style lang="scss" scoped>
.mobile-search-form {
width: 100%;
}
.mobile-search-wrapper {
.search-bar {
display: flex;
gap: 8px;
padding: 12px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
.el-input {
flex: 1;
}
.filter-btn {
flex-shrink: 0;
padding: 0 12px;
font-size: 14px;
min-width: 60px;
}
}
.expanded-form {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #fff;
z-index: 2000;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease;
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e4e7ed;
background: #fff;
.form-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.close-btn {
font-size: 20px;
color: #909399;
}
}
.form-content {
flex: 1;
overflow-y: auto;
padding: 16px;
-webkit-overflow-scrolling: touch;
::v-deep .el-form-item {
margin-bottom: 20px;
.el-form-item__label {
font-size: 14px;
color: #606266;
padding-bottom: 8px;
}
.el-input,
.el-select,
.el-date-picker {
width: 100%;
}
}
}
.form-footer {
display: flex;
gap: 12px;
padding: 16px;
border-top: 1px solid #e4e7ed;
background: #fff;
.el-button {
flex: 1;
height: 44px;
font-size: 16px;
}
}
}
}
.desktop-search-wrapper {
::v-deep .el-form {
.el-form-item {
margin-bottom: 18px;
}
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// 桌面端隐藏移动端组件
@media (min-width: 769px) {
.mobile-search-wrapper {
display: none;
}
}
// 移动端隐藏桌面端组件
@media (max-width: 768px) {
.desktop-search-wrapper {
display: none;
}
}
</style>

View File

@@ -0,0 +1,403 @@
<template>
<div class="mobile-table-container">
<!-- 移动端卡片式列表 -->
<div v-if="isMobile" class="mobile-card-list">
<div
v-for="(row, index) in data"
:key="getRowKey(row, index)"
class="mobile-card-item"
:class="{ 'selected': isSelected(row) }"
@click="handleCardClick(row, index)"
>
<!-- 卡片头部 -->
<div class="card-header" v-if="showHeader">
<div class="card-title">
<slot name="header" :row="row" :index="index">
{{ getHeaderText(row) }}
</slot>
</div>
<div class="card-actions" v-if="showSelection || showActions">
<el-checkbox
v-if="showSelection"
:value="isSelected(row)"
@click.stop="handleSelect(row)"
@change="handleSelectChange(row, $event)"
/>
<el-dropdown
v-if="showActions"
trigger="click"
@command="(cmd) => handleAction(cmd, row)"
@click.stop
>
<span class="action-btn" @click.stop>
<i class="el-icon-more"></i>
</span>
<el-dropdown-menu slot="dropdown">
<slot name="actions" :row="row" :index="index">
<el-dropdown-item
v-for="action in actions"
:key="action.key"
:command="action.key"
:disabled="action.disabled && action.disabled(row)"
>
<i :class="action.icon" v-if="action.icon"></i>
{{ action.label }}
</el-dropdown-item>
</slot>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<!-- 卡片内容 -->
<div class="card-content">
<slot name="content" :row="row" :index="index">
<div
v-for="column in columns"
:key="column.prop || column.key"
class="card-field"
v-if="column.visible !== false"
>
<div class="field-label">{{ column.label }}</div>
<div class="field-value">
<slot
:name="`column-${column.prop || column.key}`"
:row="row"
:column="column"
:value="getFieldValue(row, column)"
>
<dict-tag
v-if="column.dictType"
:options="getDictOptions(column.dictType)"
:value="getFieldValue(row, column)"
/>
<span v-else-if="column.type === 'switch'">
<el-switch
:value="getFieldValue(row, column)"
:active-value="column.activeValue"
:inactive-value="column.inactiveValue"
@change="(val) => handleSwitchChange(row, column, val)"
disabled
/>
</span>
<span v-else>{{ formatValue(getFieldValue(row, column), column) }}</span>
</slot>
</div>
</div>
</slot>
</div>
<!-- 卡片底部操作 -->
<div class="card-footer" v-if="showFooter">
<slot name="footer" :row="row" :index="index">
<el-button
v-for="btn in footerButtons"
:key="btn.key"
:type="btn.type || 'text'"
:size="btn.size || 'small'"
:icon="btn.icon"
:disabled="btn.disabled && btn.disabled(row)"
@click.stop="handleButtonClick(btn, row)"
>
{{ btn.label }}
</el-button>
</slot>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!data || data.length === 0"
description="暂无数据"
:image-size="100"
/>
</div>
<!-- 桌面端表格 -->
<el-table
v-else
v-bind="$attrs"
:data="data"
v-on="$listeners"
>
<slot></slot>
</el-table>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'MobileTable',
inheritAttrs: false,
props: {
data: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
},
rowKey: {
type: [String, Function],
default: 'id'
},
showSelection: {
type: Boolean,
default: false
},
showActions: {
type: Boolean,
default: false
},
showHeader: {
type: Boolean,
default: true
},
showFooter: {
type: Boolean,
default: true
},
actions: {
type: Array,
default: () => []
},
footerButtons: {
type: Array,
default: () => []
},
selectedRows: {
type: Array,
default: () => []
},
headerField: {
type: String,
default: 'name'
}
},
computed: {
...mapGetters(['device']),
isMobile() {
return this.device === 'mobile' || window.innerWidth < 768
}
},
methods: {
getRowKey(row, index) {
if (typeof this.rowKey === 'function') {
return this.rowKey(row, index)
}
return row[this.rowKey] || index
},
isSelected(row) {
if (!this.showSelection || !this.selectedRows) return false
const key = this.getRowKey(row)
return this.selectedRows.some(r => this.getRowKey(r) === key)
},
handleSelect(row) {
this.$emit('select', row)
},
handleSelectChange(row, selected) {
this.$emit('selection-change', row, selected)
},
handleCardClick(row, index) {
this.$emit('row-click', row, index)
},
handleAction(command, row) {
this.$emit('action', command, row)
},
handleButtonClick(btn, row) {
if (btn.handler) {
btn.handler(row)
}
this.$emit('button-click', btn.key, row)
},
getFieldValue(row, column) {
if (column.formatter) {
return column.formatter(row, column, row[column.prop], index)
}
if (typeof column.prop === 'function') {
return column.prop(row)
}
return column.prop ? this.getNestedValue(row, column.prop) : ''
},
getNestedValue(obj, path) {
return path.split('.').reduce((o, p) => o && o[p], obj)
},
formatValue(value, column) {
if (value === null || value === undefined) return '-'
if (column.formatter) {
return column.formatter(null, column, value)
}
return value
},
getHeaderText(row) {
if (this.headerField) {
return this.getNestedValue(row, this.headerField) || '未命名'
}
return row.name || row.title || '未命名'
},
getDictOptions(dictType) {
// 这里需要根据实际的字典获取方式来实现
// 可以通过 this.$store 或 this.$parent 获取字典数据
try {
const dictData = this.$store?.state?.dict?.dict || {}
return dictData[dictType] || []
} catch (e) {
return []
}
},
handleSwitchChange(row, column, value) {
this.$emit('switch-change', row, column, value)
}
}
}
</script>
<style lang="scss" scoped>
.mobile-table-container {
width: 100%;
}
.mobile-card-list {
padding: 10px;
background: #f5f5f5;
min-height: 200px;
}
.mobile-card-item {
background: #fff;
border-radius: 12px;
margin-bottom: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
&.selected {
border: 2px solid #409eff;
background: #f0f9ff;
}
&:last-child {
margin-bottom: 0;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
.card-title {
flex: 1;
font-size: 16px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
.action-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f5f5f5;
color: #606266;
cursor: pointer;
transition: all 0.3s;
&:active {
background: #e4e7ed;
}
i {
font-size: 18px;
}
}
}
}
.card-content {
.card-field {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.field-label {
font-size: 13px;
color: #909399;
min-width: 80px;
flex-shrink: 0;
margin-right: 12px;
}
.field-value {
flex: 1;
font-size: 14px;
color: #303133;
text-align: right;
word-break: break-all;
::v-deep .el-tag {
margin: 0;
}
}
}
}
.card-footer {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 8px;
flex-wrap: wrap;
::v-deep .el-button {
flex: 1;
min-width: 0;
padding: 8px 12px;
}
}
// 桌面端隐藏
@media (min-width: 769px) {
.mobile-card-list {
display: none;
}
}
// 移动端隐藏表格
@media (max-width: 768px) {
::v-deep .el-table {
display: none;
}
}
</style>

View File

@@ -5,7 +5,7 @@
:current-page.sync="currentPage" :current-page.sync="currentPage"
:page-size.sync="pageSize" :page-size.sync="pageSize"
:layout="layout" :layout="layout"
:page-sizes="[15, 50, 100, 200,1]" :page-sizes="pageSizes"
:pager-count="pagerCount" :pager-count="pagerCount"
:total="total" :total="total"
v-bind="$attrs" v-bind="$attrs"
@@ -31,18 +31,18 @@ export default {
}, },
limit: { limit: {
type: Number, type: Number,
default: 15 default: 50
}, },
pageSizes: { pageSizes: {
type: Array, type: Array,
default() { default() {
return [15, 50, 100, 200,1] return [10, 50, 100, 200, 500, 1000]
} }
}, },
// 移动端页码按钮的数量端默认值5 // 移动端页码按钮的数量端默认值5
pagerCount: { pagerCount: {
type: Number, type: Number,
default: document.body.clientWidth < 992 ? 5 : 7 default: 7
}, },
layout: { layout: {
type: String, type: String,
@@ -63,8 +63,19 @@ export default {
}, },
data() { data() {
return { return {
isMobile: false
} }
}, },
mounted() {
this.isMobile = window.innerWidth < 768
if (this.isMobile && this.layout === 'total, sizes, prev, pager, next, jumper') {
this.$emit('update:layout', 'prev, pager, next')
}
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
},
computed: { computed: {
currentPage: { currentPage: {
get() { get() {
@@ -84,6 +95,13 @@ export default {
} }
}, },
methods: { methods: {
handleResize() {
const wasMobile = this.isMobile
this.isMobile = window.innerWidth < 768
if (wasMobile !== this.isMobile) {
this.$forceUpdate()
}
},
handleSizeChange(val) { handleSizeChange(val) {
if (this.currentPage * val > this.total) { if (this.currentPage * val > this.total) {
this.currentPage = 1 this.currentPage = 1
@@ -106,6 +124,33 @@ export default {
<style scoped> <style scoped>
.pagination-container { .pagination-container {
background: #fff; background: #fff;
@media (max-width: 768px) {
padding: 10px 0;
::v-deep .el-pagination {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 5px;
.el-pagination__sizes,
.el-pagination__total,
.el-pagination__jump {
display: none;
}
.btn-prev,
.btn-next,
.el-pager li {
min-width: 36px;
height: 36px;
line-height: 36px;
font-size: 13px;
}
}
}
} }
.pagination-container.hidden { .pagination-container.hidden {
display: none; display: none;

View File

@@ -0,0 +1,167 @@
<template>
<div class="public-footer-nav">
<div class="nav-container">
<div
v-for="item in navItems"
:key="item.path"
class="nav-item"
:class="{ active: isActive(item.path) }"
@click="handleNavClick(item.path)"
>
<i :class="item.icon"></i>
<span class="nav-label">{{ item.label }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PublicFooterNav',
data() {
return {
navItems: [
{
label: '首页',
path: '/public/home',
icon: 'el-icon-s-home'
},
{
label: '评论生成',
path: '/tools/comment-gen',
icon: 'el-icon-edit-outline'
},
{
label: '订单提交',
path: '/public/order-submit',
icon: 'el-icon-upload2'
},
{
label: '订单搜索',
path: '/tools/order-search',
icon: 'el-icon-search'
}
]
}
},
methods: {
isActive(path) {
return this.$route.path === path
},
handleNavClick(path) {
if (this.$route.path !== path) {
this.$router.push(path)
}
}
}
}
</script>
<style scoped>
.public-footer-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid #e4e7ed;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 8px 0;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
.nav-container {
display: flex;
justify-content: space-around;
align-items: center;
max-width: 800px;
margin: 0 auto;
padding: 0 8px;
gap: 4px;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 8px 8px;
border-radius: 8px;
transition: all 0.3s ease;
flex: 1;
min-width: 0;
color: #909399;
}
.nav-item:hover {
background-color: #f5f7fa;
color: #409eff;
}
.nav-item.active {
color: #409eff;
background-color: #ecf5ff;
}
.nav-item i {
font-size: 20px;
margin-bottom: 4px;
transition: transform 0.3s ease;
}
.nav-item:hover i {
transform: scale(1.1);
}
.nav-label {
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* 响应式设计 */
@media (max-width: 480px) {
.nav-container {
padding: 0 4px;
gap: 2px;
}
.nav-item {
padding: 6px 4px;
}
.nav-item i {
font-size: 18px;
}
.nav-label {
font-size: 10px;
}
}
@media (min-width: 768px) {
.nav-container {
max-width: 900px;
padding: 0 12px;
gap: 8px;
}
.nav-item {
padding: 10px 12px;
}
.nav-item i {
font-size: 22px;
}
.nav-label {
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,498 @@
<template>
<el-dialog title="发品" :visible.sync="internalVisible" width="1200px" :close-on-click-modal="false" :close-on-press-escape="false" :destroy-on-close="false" append-to-body @close="handleClose">
<el-form :model="form" :rules="rules" ref="publishForm" label-width="110px">
<el-form-item label="文案版本" v-if="wenanOptions && wenanOptions.length > 0">
<el-select v-model="form.wenanIndex" placeholder="选择文案版本" @change="onWenanChange" style="width:100%">
<el-option v-for="(opt, idx) in wenanOptions" :key="idx" :label="opt.label" :value="idx" />
</el-select>
</el-form-item>
<el-form-item label="闲管家账号" v-if="!hideAppid">
<el-select v-model="form.appid" filterable placeholder="选择ERP应用" :loading="erpAccountLoading" @change="onAppidChange">
<el-option v-for="a in erpAccountsOptions" :key="a.value" :label="a.label" :value="a.value" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="34" show-word-limit />
</el-form-item>
<el-form-item label="文案内容" prop="content">
<el-input type="textarea" :rows="6" v-model="form.content" />
</el-form-item>
<el-form-item label="选择图片" v-if="productImages && productImages.length">
<div class="img-grid">
<div class="img-item" v-for="(img, idx) in productImages" :key="idx">
<img :src="img.url" :alt="`图片${idx+1}`" @click="handlePreviewImage(img.url)" />
<div class="img-actions">
<el-checkbox v-model="img.selected">使用</el-checkbox>
<el-button type="text" size="mini" @click="handleCopyImageUrl(img.url)">复制</el-button>
</div>
</div>
</div>
<div style="margin-top:8px;">
<el-button size="mini" @click="selectAllImages(true)">全选</el-button>
<el-button size="mini" @click="selectAllImages(false)">全不选</el-button>
<el-button size="mini" @click="invertSelection">反选</el-button>
</div>
</el-form-item>
<el-form-item label="额外图片链接">
<el-input type="textarea" :rows="3" v-model="form.extraImagesText" placeholder="每行一条图片URL" />
</el-form-item>
<el-form-item label="白底图">
<el-input v-model="form.whiteImages" placeholder="可选图片URL" />
</el-form-item>
<el-form-item label="服务项">
<el-select v-model="form.serviceSupport" multiple collapse-tags placeholder="可多选">
<el-option v-for="opt in serviceSupportOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="会员名" prop="userName">
<el-select v-model="form.userName" filterable placeholder="选择会员名" :loading="userNameLoading">
<el-option v-for="u in userNameOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
</el-form-item>
<el-form-item label="省/市/区" required>
<div style="display:flex; gap:8px; width:100%">
<el-select v-model.number="form.province" placeholder="选择省" style="flex:1" filterable @change="onProvinceChange">
<el-option v-for="p in regionOptions.provinces" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
<el-select v-model.number="form.city" placeholder="选择市" style="flex:1" filterable :disabled="!form.province" @change="onCityChange">
<el-option v-for="c in regionOptions.cities" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
<el-select v-model.number="form.district" placeholder="选择区" style="flex:1" filterable :disabled="!form.city">
<el-option v-for="a in regionOptions.areas" :key="a.value" :label="a.label" :value="a.value" />
</el-select>
</div>
</el-form-item>
<el-form-item label="价格(元)" prop="price">
<el-input v-model.number="form.price" type="number" min="0.01" step="0.01" />
</el-form-item>
<el-form-item label="原价(元)">
<el-input v-model.number="form.originalPrice" type="number" min="0" step="0.01" />
</el-form-item>
<el-form-item label="运费(元)" prop="expressFee">
<el-input v-model.number="form.expressFee" type="number" min="0" step="0.01" />
</el-form-item>
<el-form-item label="库存" prop="stock">
<el-input v-model.number="form.stock" type="number" min="1" />
</el-form-item>
<el-form-item label="商家编码">
<el-input v-model="form.outerId" maxlength="64" />
</el-form-item>
<el-form-item label="商品类型" prop="itemBizType">
<el-select v-model.number="form.itemBizType" filterable @change="onItemBizTypeChange">
<el-option v-for="opt in itemBizTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="行业类型" prop="spBizType">
<el-select v-model.number="form.spBizType" filterable @change="onSpBizTypeChange">
<el-option v-for="opt in spBizTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="类目ID" prop="channelCatId">
<el-select v-model="form.channelCatId" filterable placeholder="请选择类目" :disabled="!categoryOptions.length" :loading="categoryLoading" @change="loadProperties">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
<el-form-item label="商品属性">
<div v-if="pvOptions.length" style="display:flex; flex-direction:column; gap:8px;">
<div v-for="(p, pi) in pvOptions" :key="p.propertyId" style="display:flex; gap:8px; align-items:center;">
<span style="width:90px; text-align:right; color:#666;">{{ p.propertyName }}:</span>
<el-select v-model="selectedPv[p.propertyId]" clearable filterable placeholder="请选择" style="flex:1" @change="onPvChange">
<el-option v-for="v in p.values" :key="v.valueId" :label="v.valueName" :value="v.valueId" />
</el-select>
</div>
</div>
<div v-else style="color:#999;">无属性或请选择类型和类目后加载</div>
</el-form-item>
<el-form-item label="成色">
<el-select v-model.number="form.stuffStatus" clearable filterable placeholder="可选">
<el-option v-for="opt in stuffStatusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="属性JSON">
<el-input type="textarea" :rows="3" v-model="form.channelPvJson" placeholder='示例: [{"property_id":"p","property_name":"颜色","value_id":"v","value_name":"红"}]' />
</el-form-item>
</el-form>
<el-alert
v-if="createdProduct"
:title="`商品ID${createdProduct.productId || '-'}`"
type="success"
:closable="false"
show-icon
style="margin: 10px 0;"
>
<template slot="description">
<div style="display:flex; align-items:center; flex-wrap:wrap; gap:12px;">
<span>状态{{ createdProduct.productStatus || '-' }}</span>
<span>商家编码{{ createdProduct.outerId || '-' }}</span>
<el-button type="text" size="mini" @click="copyText(String(createdProduct.productId || ''))">复制ID</el-button>
<el-button type="text" size="mini" @click="copyText(String(createdProduct.outerId || ''))">复制商家编码</el-button>
</div>
</template>
</el-alert>
<div slot="footer" class="dialog-footer">
<el-button @click="closeDialog"> </el-button>
<el-button type="primary" :loading="loading" @click="submitPublish"> </el-button>
<el-button
type="warning"
:disabled="!createdProduct || !createdProduct.productId"
:loading="publishLoading"
@click="publishNow"
> </el-button>
</div>
</el-dialog>
</template>
<script>
import { createProductByPromotion, publishProduct, getProvinces, getCities, getAreas, getCategories, getUsernames, getERPAccounts, getProperties } from "@/api/system/jdorder";
export default {
name: 'PublishDialog',
props: {
visible: { type: Boolean, default: false },
initialData: { type: Object, default: () => ({}) },
hideAppid: { type: Boolean, default: false }
},
data() {
return {
internalVisible: false,
loading: false,
publishLoading: false,
createdProduct: null,
wenanOptions: [],
productImages: [],
form: {
appid: '',
userName: '',
province: null,
city: null,
district: null,
title: '',
content: '',
wenanIndex: 0,
extraImagesText: '',
whiteImages: '',
serviceSupport: ['NFR'],
price: null,
originalPrice: null,
expressFee: 0,
stock: 999,
outerId: '',
itemBizType: 2,
spBizType: 3,
channelCatId: '',
stuffStatus: 100,
channelPvJson: ''
},
rules: {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入文案内容', trigger: 'blur' }],
userName: [{ required: true, message: '请输入闲鱼会员名', trigger: 'blur' }],
price: [{ required: true, message: '请输入价格(分)', trigger: 'blur' }],
expressFee: [{ required: true, message: '请输入运费(分)', trigger: 'blur' }],
stock: [{ required: true, message: '请输入库存', trigger: 'blur' }],
itemBizType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
spBizType: [{ required: true, message: '请选择行业类型', trigger: 'change' }],
channelCatId: [{ required: true, message: '请输入类目ID', trigger: 'blur' }]
},
regionOptions: { provinces: [], cities: [], areas: [] },
categoryOptions: [],
categoryLoading: false,
userNameOptions: [],
userNameLoading: false,
erpAccountsOptions: [],
erpAccountLoading: false,
pvOptions: [],
selectedPv: {},
itemBizTypeOptions: [
{ label: '普通商品', value: 2 },
{ label: '已验货', value: 0 },
{ label: '验货宝', value: 10 },
{ label: '闲鱼优品', value: 19 },
{ label: '闲鱼特卖', value: 24 },
{ label: '品牌捡漏', value: 26 }
],
spBizTypeOptions: [
{ label: '手机', value: 1 },
{ label: '时尚', value: 2 },
{ label: '家电', value: 3 },
{ label: '乐器', value: 8 },
{ label: '数码3C', value: 9 },
{ label: '奢品', value: 16 },
{ label: '母婴', value: 17 },
{ label: '美妆', value: 18 },
{ label: '珠宝', value: 19 },
{ label: '游戏', value: 20 },
{ label: '家居', value: 21 },
{ label: '虚拟', value: 22 },
{ label: '图书', value: 24 },
{ label: '食品', value: 27 },
{ label: '玩具', value: 28 },
{ label: '其他', value: 99 }
],
stuffStatusOptions: [
{ label: '不传', value: null },
{ label: '全新', value: 100 },
{ label: '99新', value: 99 },
{ label: '95新', value: 95 },
{ label: '9成新', value: 90 },
{ label: '8成新', value: 80 },
{ label: '7成新', value: 70 },
{ label: '6成新', value: 60 },
{ label: '5成新', value: 50 }
],
serviceSupportOptions: [
{ label: '七天无理由退货', value: 'SDR' },
{ label: '描述不符包邮退', value: 'NFR' },
{ label: '描述不符全额退(虚拟)', value: 'VNR' },
{ label: '10分钟极速发货(虚拟)', value: 'FD_10MS' },
{ label: '24小时极速发货', value: 'FD_24HS' },
{ label: '48小时极速发货', value: 'FD_48HS' },
{ label: '正品保障', value: 'FD_GPA' }
]
}
},
watch: {
visible: {
immediate: true,
handler(v) { this.internalVisible = v; if (v) { this.bootstrap(); } }
},
'form.itemBizType'(val) { this.onItemBizTypeChange(); },
'form.spBizType'(val) { this.onSpBizTypeChange(); },
'form.appid'(val) { this.onAppidChange(); },
'form.channelCatId'(val) { this.loadProperties(); }
},
methods: {
copyText(text) {
const val = String(text || '').trim();
if (!val) { this.$modal.msgWarning('无可复制的内容'); return; }
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(val).then(() => { this.$modal.msgSuccess('复制成功'); }).catch(() => { this.fallbackCopy(val); });
} else {
this.fallbackCopy(val);
}
},
fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); this.$modal.msgSuccess('复制成功'); } catch(e){ this.$modal.msgError('复制失败'); }
document.body.removeChild(ta);
},
async bootstrap() {
// 初始化表单与文案/图片
const d = this.initialData || {};
this.wenanOptions = Array.isArray(d.wenanOptions) ? d.wenanOptions : [];
this.form.title = d.title || '';
this.form.content = d.content || '';
this.form.wenanIndex = 0;
if (Array.isArray(d.images)) {
this.productImages = d.images.map(u => ({ url: u, selected: true }));
} else { this.productImages = []; }
// 预估原价
if (typeof d.originalPrice === 'number') {
this.form.originalPrice = d.originalPrice;
}
// 预设:会员名、省市区
if (d.userName) this.form.userName = d.userName;
if (d.province) this.form.province = d.province;
if (d.city) this.form.city = d.city;
if (d.district) this.form.district = d.district;
await this.loadProvinces();
await this.loadERPAccounts();
await this.loadUsernames();
await this.loadCategories();
this.$nextTick(() => this.$refs.publishForm && this.$refs.publishForm.clearValidate());
},
onWenanChange(val) {
if (this.wenanOptions && this.wenanOptions[val]) {
this.form.content = this.wenanOptions[val].content || '';
}
},
selectAllImages(flag) { (this.productImages || []).forEach(it => { it.selected = !!flag; }); },
invertSelection() { (this.productImages || []).forEach(it => { it.selected = !it.selected; }); },
handleCopyImageUrl(imageUrl) { if (navigator.clipboard) { navigator.clipboard.writeText(imageUrl).then(() => { this.$modal.msgSuccess('图片链接复制成功'); }).catch(() => { this.$message.error('复制失败'); }); } },
handlePreviewImage(imageUrl) { window.open(imageUrl, '_blank'); },
async loadProvinces(echo = true) {
try {
const res = await getProvinces();
if (res.code === 200) this.regionOptions.provinces = res.data || []; else this.$modal.msgError(res.msg || '加载省份失败');
} catch(e){ this.$modal.msgError('加载省份失败'); }
if (!this.form.province && this.regionOptions.provinces.length) {
this.form.province = this.regionOptions.provinces[0].value;
}
if (this.form.province) { await this.loadCities(this.form.province, true); } else { this.regionOptions.cities = []; this.regionOptions.areas = []; this.form.city = null; this.form.district = null; }
},
async onProvinceChange() { await this.loadCities(this.form.province, false); },
async onCityChange() { await this.loadAreas(this.form.province, this.form.city, false); },
async loadCities(provId, echo = false) {
if (!provId) { this.regionOptions.cities = []; this.regionOptions.areas = []; this.form.city = null; this.form.district = null; return; }
try { const res = await getCities(provId); if (res.code === 200) this.regionOptions.cities = res.data || []; else this.$modal.msgError(res.msg || '加载城市失败'); } catch(e){ this.$modal.msgError('加载城市失败'); }
if (!this.form.city && this.regionOptions.cities.length) {
this.form.city = this.regionOptions.cities[0].value;
}
if (this.form.city) { await this.loadAreas(provId, this.form.city, true); } else { this.regionOptions.areas = []; this.form.district = null; }
},
async loadAreas(provId, cityId, echo = false) {
if (!provId || !cityId) { this.regionOptions.areas = []; this.form.district = null; return; }
try { const res = await getAreas(provId, cityId); if (res.code === 200) this.regionOptions.areas = res.data || []; else this.$modal.msgError(res.msg || '加载区县失败'); } catch(e){ this.$modal.msgError('加载区县失败'); }
if (!this.form.district && this.regionOptions.areas.length) {
this.form.district = this.regionOptions.areas[0].value;
} else if (!echo) {
this.form.district = null;
}
},
async loadERPAccounts() {
this.erpAccountLoading = true;
try {
const res = await getERPAccounts();
if (res.code === 200) this.erpAccountsOptions = res.data || []; else this.$modal.msgError(res.msg || '加载应用失败');
} catch(e){ this.$modal.msgError('加载应用失败'); }
this.erpAccountLoading = false;
// 如果隐藏appid选择则不强制赋值给表单由后端使用默认账号
if (!this.hideAppid && !this.form.appid && this.erpAccountsOptions.length) {
this.form.appid = this.erpAccountsOptions[0].value;
}
},
onAppidChange() { this.form.userName = ''; this.loadUsernames(); this.loadCategories(); this.loadProperties(); },
async loadUsernames() {
this.userNameLoading = true;
try { const res = await getUsernames({ pageNum: 1, pageSize: 200, appid: this.form.appid }); if (res.code === 200) this.userNameOptions = res.data || []; else this.$modal.msgError(res.msg || '加载会员名失败'); } catch(e){ this.$modal.msgError('加载会员名失败'); }
this.userNameLoading = false; if (!this.form.userName && this.userNameOptions.length) { this.form.userName = this.userNameOptions[0].value; }
},
async onItemBizTypeChange() { this.categoryOptions = []; this.form.channelCatId = ''; await this.loadCategories(); },
async onSpBizTypeChange() { this.categoryOptions = []; this.form.channelCatId = ''; await this.loadCategories(); },
async loadCategories() {
const itemBizType = this.form.itemBizType; const spBizType = this.form.spBizType; const appid = this.form.appid; if (!itemBizType) return;
this.categoryLoading = true;
try { const res = await getCategories({ itemBizType, spBizType, appid }); if (res.code === 200) this.categoryOptions = res.data || []; else this.$modal.msgError(res.msg || '加载类目失败'); } catch(e){ this.$modal.msgError('加载类目失败'); }
this.categoryLoading = false;
if (this.form.channelCatId) { this.loadProperties(); } else if (this.categoryOptions.length) { this.form.channelCatId = this.categoryOptions[0].value; this.loadProperties(); }
},
async loadProperties() {
const f = this.form; if (!f.itemBizType || !f.spBizType || !f.channelCatId) { this.pvOptions = []; this.selectedPv = {}; return; }
try {
const res = await getProperties({ itemBizType: f.itemBizType, spBizType: f.spBizType, channelCatId: f.channelCatId, appid: f.appid });
if (res.code === 200) { this.pvOptions = res.data || []; const keep = { ...this.selectedPv }; this.selectedPv = {}; (this.pvOptions || []).forEach(p => { if (keep[p.propertyId]) this.selectedPv[p.propertyId] = keep[p.propertyId]; }); }
else { this.$modal.msgError(res.msg || '加载属性失败'); }
} catch(e) { this.$modal.msgError('加载属性失败'); }
},
onPvChange() {
// 属性值变更时的回调(可用于调试或联动逻辑)
},
submitPublish() {
this.$refs.publishForm.validate(valid => {
if (!valid) return;
const f = this.form;
const selectedImages = (this.productImages || []).filter(it => it.selected).map(it => it.url).filter(Boolean);
const extraImages = String(f.extraImagesText || '').split(/\n+/).map(s => s.trim()).filter(Boolean);
const images = [...selectedImages, ...extraImages];
if (!images.length) { this.$modal.msgError('请至少选择或填写一张图片'); return; }
let channelPv = undefined;
if (f.channelPvJson && f.channelPvJson.trim()) {
try { channelPv = JSON.parse(f.channelPvJson); }
catch(e) { this.$modal.msgError('属性JSON格式不正确'); return; }
} else if (this.selectedPv && Object.keys(this.selectedPv).length) {
// 从 pvOptions 中获取完整的属性信息property_id, property_name, value_id, value_name
channelPv = [];
Object.keys(this.selectedPv).forEach(pid => {
const valueId = this.selectedPv[pid];
if (!valueId) return; // 跳过未选择的属性
// 从 pvOptions 中找到对应的属性
const property = this.pvOptions.find(p => String(p.propertyId) === String(pid));
if (!property) return;
// 从属性的 values 中找到对应的值
const value = property.values && property.values.find(v => String(v.valueId) === String(valueId));
if (!value) return;
// 构建完整的4个字段
channelPv.push({
property_id: pid,
property_name: property.propertyName,
value_id: valueId,
value_name: value.valueName
});
});
if (channelPv.length === 0) channelPv = undefined;
}
const payload = {
appid: f.appid || undefined,
title: f.title,
content: f.content,
images: images,
whiteImages: f.whiteImages || undefined,
userName: f.userName,
province: f.province,
city: f.city,
district: f.district,
serviceSupport: (f.serviceSupport && f.serviceSupport.length) ? f.serviceSupport.join(',') : undefined,
price: cents(f.price),
originalPrice: f.originalPrice != null ? cents(f.originalPrice) : undefined,
expressFee: cents(f.expressFee),
stock: f.stock,
outerId: f.outerId || undefined,
itemBizType: f.itemBizType,
spBizType: f.spBizType,
channelCatId: f.channelCatId,
stuffStatus: f.stuffStatus || undefined,
// 后端字段为下划线命名
channel_pv: channelPv
};
function cents(yuan){ const n = Number(yuan); if (Number.isNaN(n)) return undefined; return Math.round(n*100); }
this.loading = true;
createProductByPromotion(payload).then(async res => {
this.loading = false;
if (res.code === 200) {
try {
const outerId = res.data && (res.data.outerId || (res.data.data && res.data.data.outerId));
if (outerId) this.$modal.msgSuccess(`发品成功,商家编码:${outerId}`); else this.$modal.msgSuccess('发品提交成功');
} catch(e){ this.$modal.msgSuccess('发品提交成功'); }
// 记录创建成功的商品,保留弹窗供手动“上架”
const productId = this.extractProductId(res.data) || (res.data && (res.data.product_id || (res.data.data && res.data.data.product_id)));
const productStatus = res.data && (res.data.product_status || (res.data.data && res.data.data.product_status));
const outerId2 = res.data && (res.data.outerId || res.data.outer_id || (res.data.data && (res.data.data.outerId || res.data.data.outer_id)));
this.createdProduct = { productId, productStatus, outerId: outerId2 };
this.$emit('success', res);
} else { this.$modal.msgError(res.msg || '发品失败'); }
}).catch(err => { this.loading = false; console.error('发品失败', err); this.$modal.msgError('发品失败,请稍后重试'); });
});
},
async publishNow() {
if (!this.createdProduct || !this.createdProduct.productId) return;
this.publishLoading = true;
try {
const pubRes = await publishProduct({ productId: this.createdProduct.productId, userName: this.form.userName, appid: this.form.appid });
if (pubRes && pubRes.code === 200) {
const code = (pubRes.data && pubRes.data.code) ?? pubRes.code;
if (code === 0 || code === 200) this.$modal.msgSuccess('上架成功'); else this.$modal.msgWarning('上架已提交或状态未知');
} else {
this.$modal.msgError((pubRes && pubRes.msg) || '上架失败');
}
} catch(e) {
this.$modal.msgError('上架失败');
}
this.publishLoading = false;
},
extractProductId(resp) {
try {
if (!resp) return null;
if (typeof resp === 'object') {
if (resp.productId) return Number(resp.productId);
if (resp.data && resp.data.productId) return Number(resp.data.productId);
if (resp.data && resp.data.id) return Number(resp.data.id);
}
} catch (e) { return null; }
return null;
},
closeDialog() { this.$emit('update:visible', false); this.internalVisible = false; },
handleClose() { this.closeDialog(); }
}
}
</script>
<style scoped>
.img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
.img-item { border: 1px solid #e5e5e5; border-radius: 4px; padding: 6px; text-align: center; }
.img-item img { width: 100%; height: 100px; object-fit: cover; border-radius: 4px; }
.img-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; }
</style>

View File

@@ -151,4 +151,39 @@ export default {
background-color: #ccc; background-color: #ccc;
margin: 3px auto; margin: 3px auto;
} }
// 移动端优化
@media (max-width: 768px) {
.top-right-btn {
float: none;
margin-bottom: 10px;
width: 100%;
display: flex;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
.el-button {
min-width: 44px;
height: 44px;
padding: 0;
&.el-button--mini {
min-width: 40px;
height: 40px;
}
}
}
::v-deep .el-dialog {
width: 95% !important;
margin: 5vh auto !important;
.el-transfer {
display: flex;
flex-direction: column;
gap: 15px;
}
}
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<section class="app-main"> <section class="app-main">
<transition name="fade-transform" mode="out-in"> <transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews"> <keep-alive :include="keepAliveInclude">
<router-view v-if="!$route.meta.link" :key="key" /> <router-view v-if="!$route.meta.link" :key="key" />
</keep-alive> </keep-alive>
</transition> </transition>
@@ -21,6 +21,11 @@ export default {
cachedViews() { cachedViews() {
return this.$store.state.tagsView.cachedViews return this.$store.state.tagsView.cachedViews
}, },
// cachedViews 为空时勿传 []Vue2 keep-alive 的 include 为 [] 会匹配不到任何组件,部分环境下首屏空白
keepAliveInclude() {
const list = this.cachedViews
return list && list.length ? list : undefined
},
key() { key() {
return this.$route.path return this.$route.path
} }
@@ -51,24 +56,50 @@ export default {
width: 100%; width: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@media (max-width: 768px) {
min-height: calc(100vh - 48px - 60px); // 减去头部和底部导航
overflow-x: hidden;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
height: auto;
max-height: none;
position: static;
}
} }
.app-main:has(.copyright) { .app-main:has(.copyright) {
padding-bottom: 36px; padding-bottom: 36px;
@media (max-width: 768px) {
padding-bottom: 30px;
}
} }
.fixed-header + .app-main { .fixed-header + .app-main {
padding-top: 50px; padding-top: 50px;
@media (max-width: 768px) {
padding-top: 48px;
}
} }
.hasTagsView { .hasTagsView {
.app-main { .app-main {
/* 84 = navbar + tags-view = 50 + 34 */ /* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 84px); min-height: calc(100vh - 84px);
@media (max-width: 768px) {
min-height: calc(100vh - 48px);
}
} }
.fixed-header + .app-main { .fixed-header + .app-main {
padding-top: 84px; padding-top: 84px;
@media (max-width: 768px) {
padding-top: 48px;
}
} }
} }
</style> </style>

View File

@@ -1,7 +1,5 @@
<template> <template>
<div class="navbar"> <div class="navbar">
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb v-if="!topNav" id="breadcrumb-container" class="breadcrumb-container" /> <breadcrumb v-if="!topNav" id="breadcrumb-container" class="breadcrumb-container" />
<top-nav v-if="topNav" id="topmenu-container" class="topmenu-container" /> <top-nav v-if="topNav" id="topmenu-container" class="topmenu-container" />
@@ -43,7 +41,6 @@
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import Breadcrumb from '@/components/Breadcrumb' import Breadcrumb from '@/components/Breadcrumb'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull' import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect' import SizeSelect from '@/components/SizeSelect'
import Search from '@/components/HeaderSearch' import Search from '@/components/HeaderSearch'
@@ -55,7 +52,6 @@ export default {
components: { components: {
Breadcrumb, Breadcrumb,
TopNav, TopNav,
Hamburger,
Screenfull, Screenfull,
SizeSelect, SizeSelect,
Search, Search,
@@ -64,7 +60,6 @@ export default {
}, },
computed: { computed: {
...mapGetters([ ...mapGetters([
'sidebar',
'avatar', 'avatar',
'device', 'device',
'nickName' 'nickName'
@@ -81,9 +76,6 @@ export default {
} }
}, },
methods: { methods: {
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
setLayout(event) { setLayout(event) {
this.$emit('setLayout') this.$emit('setLayout')
}, },
@@ -94,7 +86,7 @@ export default {
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
this.$store.dispatch('LogOut').then(() => { this.$store.dispatch('LogOut').then(() => {
location.href = '/index' this.$router.push('/login')
}) })
}).catch(() => {}) }).catch(() => {})
} }
@@ -110,26 +102,27 @@ export default {
background: #fff; background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08); box-shadow: 0 1px 4px rgba(0,21,41,.08);
.hamburger-container { // 移动端优化
line-height: 46px; @media (max-width: 768px) {
height: 100%; height: 48px;
float: left; padding: 0 5px;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
&:hover {
background: rgba(0, 0, 0, .025)
}
} }
.breadcrumb-container { .breadcrumb-container {
float: left; float: left;
@media (max-width: 768px) {
display: none; // 移动端隐藏面包屑
}
} }
.topmenu-container { .topmenu-container {
position: absolute; position: absolute;
left: 50px; left: 0;
@media (max-width: 768px) {
left: 0;
}
} }
.errLog-container { .errLog-container {
@@ -141,19 +134,36 @@ export default {
float: right; float: right;
height: 100%; height: 100%;
line-height: 50px; line-height: 50px;
display: flex;
align-items: center;
gap: 5px;
@media (max-width: 768px) {
line-height: 48px;
gap: 2px;
}
&:focus { &:focus {
outline: none; outline: none;
} }
.right-menu-item { .right-menu-item {
display: inline-block; display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px; padding: 0 8px;
height: 100%; height: 100%;
min-width: 44px; // 增大触摸目标
font-size: 18px; font-size: 18px;
color: #5a5e66; color: #5a5e66;
vertical-align: text-bottom; vertical-align: text-bottom;
@media (max-width: 768px) {
padding: 0 6px;
font-size: 16px;
min-width: 40px;
}
&.hover-effect { &.hover-effect {
cursor: pointer; cursor: pointer;
transition: background .3s; transition: background .3s;
@@ -161,6 +171,10 @@ export default {
&:hover { &:hover {
background: rgba(0, 0, 0, .025) background: rgba(0, 0, 0, .025)
} }
&:active {
background: rgba(0, 0, 0, .05)
}
} }
} }
@@ -168,33 +182,71 @@ export default {
margin-right: 0px; margin-right: 0px;
padding-right: 0px; padding-right: 0px;
@media (max-width: 768px) {
margin-right: 0;
}
.avatar-wrapper { .avatar-wrapper {
margin-top: 10px; margin-top: 10px;
position: relative; position: relative;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 0 4px;
min-height: 44px; // 增大触摸目标
@media (max-width: 768px) {
margin-top: 8px;
gap: 4px;
}
.user-avatar { .user-avatar {
cursor: pointer; cursor: pointer;
width: 30px; width: 30px;
height: 30px; height: 30px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
@media (max-width: 768px) {
width: 28px;
height: 28px;
}
} }
.user-nickname{ .user-nickname{
position: relative; position: relative;
bottom: 10px;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
white-space: nowrap;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
@media (max-width: 768px) {
font-size: 13px;
max-width: 60px;
display: none; // 移动端隐藏昵称
}
} }
.el-icon-caret-bottom { .el-icon-caret-bottom {
cursor: pointer; cursor: pointer;
position: absolute;
right: -20px;
top: 25px;
font-size: 12px; font-size: 12px;
flex-shrink: 0;
@media (max-width: 768px) {
font-size: 10px;
}
} }
} }
} }
.setting {
@media (max-width: 768px) {
display: none; // 移动端隐藏设置按钮
}
}
} }
} }
</style> </style>

View File

@@ -2,40 +2,7 @@
<el-drawer size="280px" :visible="showSettings" :with-header="false" :append-to-body="true" :before-close="closeSetting" :lock-scroll="false"> <el-drawer size="280px" :visible="showSettings" :with-header="false" :append-to-body="true" :before-close="closeSetting" :lock-scroll="false">
<div class="drawer-container"> <div class="drawer-container">
<div> <div>
<div class="setting-drawer-content"> <!-- 主题风格设置已完全移除避免与自定义侧边栏样式冲突 -->
<div class="setting-drawer-title">
<h3 class="drawer-title">主题风格设置</h3>
</div>
<div class="setting-drawer-block-checbox">
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
<img src="@/assets/images/dark.svg" alt="dark">
<div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
</svg>
</i>
</div>
</div>
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
<img src="@/assets/images/light.svg" alt="light">
<div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
</svg>
</i>
</div>
</div>
</div>
<div class="drawer-item">
<span>主题颜色</span>
<theme-picker style="float: right;height: 26px;margin: -3px 8px 0 0;" @change="themeChange" />
</div>
</div>
<el-divider/>
<h3 class="drawer-title">系统布局配置</h3> <h3 class="drawer-title">系统布局配置</h3>
@@ -84,15 +51,13 @@
</template> </template>
<script> <script>
import ThemePicker from '@/components/ThemePicker' // import ThemePicker from '@/components/ThemePicker'
export default { export default {
components: { ThemePicker }, // components: { ThemePicker },
expose: ['openSetting'], expose: ['openSetting'],
data() { data() {
return { return {
theme: this.$store.state.settings.theme,
sideTheme: this.$store.state.settings.sideTheme,
showSettings: false showSettings: false
} }
}, },
@@ -181,20 +146,6 @@ export default {
} }
}, },
methods: { methods: {
themeChange(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'theme',
value: val
})
this.theme = val
},
handleTheme(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'sideTheme',
value: val
})
this.sideTheme = val
},
openSetting() { openSetting() {
this.showSettings = true this.showSettings = true
}, },
@@ -212,9 +163,7 @@ export default {
"fixedHeader":${this.fixedHeader}, "fixedHeader":${this.fixedHeader},
"sidebarLogo":${this.sidebarLogo}, "sidebarLogo":${this.sidebarLogo},
"dynamicTitle":${this.dynamicTitle}, "dynamicTitle":${this.dynamicTitle},
"footerVisible":${this.footerVisible}, "footerVisible":${this.footerVisible}
"sideTheme":"${this.sideTheme}",
"theme":"${this.theme}"
}` }`
) )
setTimeout(this.$modal.closeLoading(), 1000) setTimeout(this.$modal.closeLoading(), 1000)
@@ -229,48 +178,7 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.setting-drawer-content { /* 主题风格设置相关样式已移除 */
.setting-drawer-title {
margin-bottom: 12px;
color: rgba(0, 0, 0, .85);
font-size: 14px;
line-height: 22px;
font-weight: bold;
}
.setting-drawer-block-checbox {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 10px;
margin-bottom: 20px;
.setting-drawer-block-checbox-item {
position: relative;
margin-right: 16px;
border-radius: 2px;
cursor: pointer;
img {
width: 48px;
height: 48px;
}
.setting-drawer-block-checbox-selectIcon {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
padding-top: 15px;
padding-left: 24px;
color: #1890ff;
font-weight: 700;
font-size: 14px;
}
}
}
}
.drawer-container { .drawer-container {
padding: 20px; padding: 20px;

View File

@@ -1,13 +1,16 @@
<template> <template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }"> <div class="sidebar-logo-container" :class="{'collapse':collapse}">
<transition name="sidebarLogoFade"> <transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/"> <router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/tools/comment-gen">
<img v-if="logo" :src="logo" class="sidebar-logo" /> <div class="logo-icon">
<h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1> <i class="el-icon-chat-line-round"></i>
</div>
</router-link> </router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/"> <router-link v-else key="expand" class="sidebar-logo-link" to="/tools/comment-gen">
<img v-if="logo" :src="logo" class="sidebar-logo" /> <div class="logo-icon">
<h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1> <i class="el-icon-chat-line-round"></i>
</div>
<h1 class="sidebar-title">{{ title }}</h1>
</router-link> </router-link>
</transition> </transition>
</div> </div>
@@ -55,38 +58,96 @@ export default {
.sidebar-logo-container { .sidebar-logo-container {
position: relative; position: relative;
width: 100%; width: 100%;
height: 50px; height: 60px;
line-height: 50px; line-height: 60px;
background: #2b2f3a; background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
text-align: center; text-align: center;
overflow: hidden; overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
// 移动端优化
@media (max-width: 768px) {
height: 50px;
line-height: 50px;
}
& .sidebar-logo-link { & .sidebar-logo-link {
height: 100%; height: 100%;
width: 100%; width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
@media (max-width: 768px) {
padding: 0 15px;
}
& .sidebar-logo { & .logo-icon {
width: 32px; display: flex;
height: 32px; align-items: center;
vertical-align: middle; justify-content: center;
margin-right: 12px; width: 42px;
height: 42px;
background: rgba(255, 255, 255, 0.6);
border: 2px solid rgba(25, 118, 210, 0.3);
border-radius: 50%;
margin-right: 15px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@media (max-width: 768px) {
width: 36px;
height: 36px;
margin-right: 10px;
}
i {
font-size: 22px;
color: #1976d2;
text-shadow: none;
@media (max-width: 768px) {
font-size: 18px;
}
}
&:hover {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(25, 118, 210, 0.5);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2);
}
&:active {
transform: scale(0.95);
}
} }
& .sidebar-title { & .sidebar-title {
display: inline-block; display: inline-block;
margin: 0; margin: 0;
color: #fff; color: #1976d2;
font-weight: 600; font-weight: 600;
line-height: 50px; font-size: 16px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif; font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle; vertical-align: middle;
text-shadow: none;
@media (max-width: 768px) {
font-size: 14px;
}
} }
} }
&.collapse { &.collapse {
.sidebar-logo { .logo-icon {
margin-right: 0px; margin-right: 0;
}
.sidebar-title {
display: none;
} }
} }
} }

View File

@@ -1,10 +1,10 @@
<template> <template>
<div :class="{'has-logo':showLogo}" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }"> <div :class="{'has-logo':showLogo}" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<logo v-if="showLogo" :collapse="isCollapse" /> <logo v-if="showLogo" :collapse="false" />
<el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper"> <el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper">
<el-menu <el-menu
:default-active="activeMenu" :default-active="activeMenu"
:collapse="isCollapse" :collapse="false"
:background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground" :background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground"
:text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor" :text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
:unique-opened="true" :unique-opened="true"
@@ -48,9 +48,6 @@ export default {
}, },
variables() { variables() {
return variables return variables
},
isCollapse() {
return !this.sidebar.opened
} }
} }
} }

View File

@@ -244,6 +244,14 @@ export default {
background: #fff; background: #fff;
border-bottom: 1px solid #d8dce5; border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
// 移动端优化
@media (max-width: 768px) {
height: 0;
overflow: hidden;
display: none; // 移动端隐藏标签页
}
.tags-view-wrapper { .tags-view-wrapper {
.tags-view-item { .tags-view-item {
display: inline-block; display: inline-block;
@@ -258,11 +266,30 @@ export default {
font-size: 12px; font-size: 12px;
margin-left: 5px; margin-left: 5px;
margin-top: 4px; margin-top: 4px;
white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
@media (max-width: 768px) {
max-width: 120px;
font-size: 11px;
padding: 0 6px;
}
&:first-of-type { &:first-of-type {
margin-left: 15px; margin-left: 15px;
@media (max-width: 768px) {
margin-left: 10px;
}
} }
&:last-of-type { &:last-of-type {
margin-right: 15px; margin-right: 15px;
@media (max-width: 768px) {
margin-right: 10px;
}
} }
&.active { &.active {
background-color: #42b983; background-color: #42b983;
@@ -298,13 +325,39 @@ export default {
font-weight: 400; font-weight: 400;
color: #333; color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3); box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
min-width: 120px;
@media (max-width: 768px) {
font-size: 14px;
min-width: 140px;
padding: 8px 0;
}
li { li {
margin: 0; margin: 0;
padding: 7px 16px; padding: 7px 16px;
cursor: pointer; cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
@media (max-width: 768px) {
padding: 10px 16px;
min-height: 44px;
}
&:hover { &:hover {
background: #eee; background: #eee;
} }
&:active {
background: #ddd;
}
i {
margin-right: 8px;
font-size: 14px;
}
} }
} }
} }

View File

@@ -1,22 +1,29 @@
<template> <template>
<div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}"> <div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/> <div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
<sidebar v-if="!sidebar.hide" class="sidebar-container"/> <sidebar v-if="!sidebar.hide && device !== 'mobile'" class="sidebar-container"/>
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container"> <div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide, 'mobile-layout': device === 'mobile'}" class="main-container">
<div :class="{'fixed-header':fixedHeader}"> <div :class="{'fixed-header':fixedHeader}">
<navbar @setLayout="setLayout"/> <navbar @setLayout="setLayout"/>
<tags-view v-if="needTagsView"/> <tags-view v-if="needTagsView && device !== 'mobile'"/>
</div> </div>
<app-main/> <app-main/>
<settings ref="settingRef"/> <settings ref="settingRef"/>
</div> </div>
<!-- 移动端底部导航key 随接口路由更新确保菜单与跳转使用最新路由 -->
<mobile-bottom-nav
v-if="device === 'mobile'"
:key="mobileNavKey"
:items="mobileNavItems"
/>
</div> </div>
</template> </template>
<script> <script>
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components' import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import MobileBottomNav from '@/components/MobileBottomNav'
import ResizeMixin from './mixin/ResizeHandler' import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex' import { mapState, mapGetters } from 'vuex'
import variables from '@/assets/styles/variables.scss' import variables from '@/assets/styles/variables.scss'
export default { export default {
@@ -26,7 +33,8 @@ export default {
Navbar, Navbar,
Settings, Settings,
Sidebar, Sidebar,
TagsView TagsView,
MobileBottomNav
}, },
mixins: [ResizeMixin], mixins: [ResizeMixin],
computed: { computed: {
@@ -38,10 +46,27 @@ export default {
needTagsView: state => state.settings.tagsView, needTagsView: state => state.settings.tagsView,
fixedHeader: state => state.settings.fixedHeader fixedHeader: state => state.settings.fixedHeader
}), }),
...mapGetters(['sidebarRouters']),
/** 接口路由更新后变化,使底部导航重新渲染并拉取最新菜单,避免点击跳错页 */
mobileNavKey() {
const routes = this.sidebarRouters || []
const len = routes.length
const firstPath = (len && routes[0]) ? (routes[0].path || '') : ''
return `${len}-${firstPath}`
},
mobileNavItems() {
return [
{ path: '/sloworder/index', label: '慢单', icon: 'el-icon-tickets' },
{ path: '/jd-instruction/index', label: '中控', icon: 'el-icon-s-operation' },
{ path: '/mobile/fadan', label: '发单', icon: 'el-icon-edit-outline' },
{ path: '/mobile/xianyu-publish', label: '发品', icon: 'el-icon-goods' },
{ path: '/mobile/zhongcao', label: '种草', icon: 'el-icon-chat-dot-round' }
]
},
classObj() { classObj() {
return { return {
hideSidebar: !this.sidebar.opened, hideSidebar: false, // 侧边栏始终展开
openSidebar: this.sidebar.opened, openSidebar: true, // 侧边栏始终展开
withoutAnimation: this.sidebar.withoutAnimation, withoutAnimation: this.sidebar.withoutAnimation,
mobile: this.device === 'mobile' mobile: this.device === 'mobile'
} }
@@ -75,6 +100,18 @@ export default {
position: fixed; position: fixed;
top: 0; top: 0;
} }
@media (max-width: 768px) {
&.mobile {
height: auto;
min-height: 100vh;
position: relative;
&.openSidebar {
position: relative;
}
}
}
} }
.drawer-bg { .drawer-bg {
@@ -107,4 +144,43 @@ export default {
.mobile .fixed-header { .mobile .fixed-header {
width: 100%; width: 100%;
} }
// 移动端优化
@media (max-width: 768px) {
.app-wrapper {
&.mobile {
.sidebar-container {
display: none; // 移动端完全隐藏侧边栏,使用底部导航
}
}
.drawer-bg {
display: none; // 移动端不需要遮罩
}
.main-container {
margin-left: 0 !important;
width: 100%;
height: auto !important;
min-height: 100vh;
overflow: visible;
&.mobile-layout {
padding-bottom: 60px; // 为底部导航预留空间
}
}
.fixed-header {
width: 100% !important;
left: 0;
}
// 移动端隐藏标签页
.hasTagsView {
.fixed-header + .app-main {
padding-top: 48px !important;
}
}
}
}
</style> </style>

View File

@@ -1,7 +1,7 @@
import store from '@/store' import store from '@/store'
const { body } = document const { body } = document
const WIDTH = 992 // refer to Bootstrap's responsive design const WIDTH = 768 // 移动端断点调整为 768px更符合移动设备标准
export default { export default {
watch: { watch: {

View File

@@ -16,6 +16,7 @@ import { download } from '@/utils/request'
import './assets/icons' // icon import './assets/icons' // icon
import './permission' // permission control import './permission' // permission control
import { initMobile } from '@/utils/mobile' // 移动端初始化
import { getDicts } from "@/api/system/dict/data" import { getDicts } from "@/api/system/dict/data"
import { getConfigKey } from "@/api/system/config" import { getConfigKey } from "@/api/system/config"
import { parseTime, resetForm, addDateRange, selectDictLabel, selectDictLabels, handleTree } from "@/utils/ruoyi" import { parseTime, resetForm, addDateRange, selectDictLabel, selectDictLabels, handleTree } from "@/utils/ruoyi"
@@ -35,6 +36,11 @@ import ImagePreview from "@/components/ImagePreview"
import DictTag from '@/components/DictTag' import DictTag from '@/components/DictTag'
// 字典数据组件 // 字典数据组件
import DictData from '@/components/DictData' import DictData from '@/components/DictData'
// 移动端组件
import MobileTable from '@/components/MobileTable'
import MobileSearchForm from '@/components/MobileSearchForm'
import MobileButtonGroup from '@/components/MobileButtonGroup'
import MobileBottomNav from '@/components/MobileBottomNav'
// 全局方法挂载 // 全局方法挂载
Vue.prototype.getDicts = getDicts Vue.prototype.getDicts = getDicts
@@ -55,6 +61,11 @@ Vue.component('Editor', Editor)
Vue.component('FileUpload', FileUpload) Vue.component('FileUpload', FileUpload)
Vue.component('ImageUpload', ImageUpload) Vue.component('ImageUpload', ImageUpload)
Vue.component('ImagePreview', ImagePreview) Vue.component('ImagePreview', ImagePreview)
// 移动端组件
Vue.component('MobileTable', MobileTable)
Vue.component('MobileSearchForm', MobileSearchForm)
Vue.component('MobileButtonGroup', MobileButtonGroup)
Vue.component('MobileBottomNav', MobileBottomNav)
Vue.use(directive) Vue.use(directive)
Vue.use(plugins) Vue.use(plugins)
@@ -75,6 +86,9 @@ Vue.use(Element, {
Vue.config.productionTip = false Vue.config.productionTip = false
// 初始化移动端优化
initMobile()
new Vue({ new Vue({
el: '#app', el: '#app',
router, router,

91
src/mixins/mobile.js Normal file
View File

@@ -0,0 +1,91 @@
/**
* 移动端混入
*/
import { mapGetters } from 'vuex'
import { isMobile, isIOS, isAndroid, getDeviceType } from '@/utils/mobile'
export default {
computed: {
...mapGetters(['device']),
$isMobile() {
return this.device === 'mobile' || isMobile()
},
$isIOS() {
return isIOS()
},
$isAndroid() {
return isAndroid()
},
$deviceType() {
return getDeviceType()
},
$isTablet() {
return window.innerWidth >= 768 && window.innerWidth < 1024
},
$isPhone() {
return window.innerWidth < 768
}
},
methods: {
/**
* 移动端提示
*/
$mobileToast(message, type = 'info') {
if (this.$isMobile) {
this.$message({
message,
type,
duration: 2000,
showClose: false
})
} else {
this.$message({
message,
type
})
}
},
/**
* 移动端确认对话框
*/
$mobileConfirm(message, title = '提示', options = {}) {
return this.$confirm(message, title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
...options
})
},
/**
* 移动端表格列过滤
*/
$filterMobileColumns(columns) {
if (this.$isMobile) {
// 移动端只显示重要列
return columns.filter(col => {
if (col.mobile === false) return false
if (col.mobile === true) return true
// 默认显示前5列
return columns.indexOf(col) < 5
})
}
return columns
},
/**
* 格式化移动端显示值
*/
$formatMobileValue(value, column) {
if (value === null || value === undefined || value === '') {
return '-'
}
if (column && column.formatter) {
return column.formatter(null, column, value)
}
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
}
}

View File

@@ -9,7 +9,7 @@ import { isRelogin } from '@/utils/request'
NProgress.configure({ showSpinner: false }) NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register'] const whiteList = ['/login', '/register', '/public/home', '/tools/comment-gen', '/tools/order-search', '/public/order-submit', '/kdocs-callback', '/tendoc-callback']
const isWhiteList = (path) => { const isWhiteList = (path) => {
return whiteList.some(pattern => isPathMatch(pattern, path)) return whiteList.some(pattern => isPathMatch(pattern, path))
@@ -21,7 +21,7 @@ router.beforeEach((to, from, next) => {
to.meta.title && store.dispatch('settings/setTitle', to.meta.title) to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
/* has token*/ /* has token*/
if (to.path === '/login') { if (to.path === '/login') {
next({ path: '/' }) next({ path: '/user/profile' })
NProgress.done() NProgress.done()
} else if (isWhiteList(to.path)) { } else if (isWhiteList(to.path)) {
next() next()
@@ -39,7 +39,7 @@ router.beforeEach((to, from, next) => {
}).catch(err => { }).catch(err => {
store.dispatch('LogOut').then(() => { store.dispatch('LogOut').then(() => {
Message.error(err) Message.error(err)
next({ path: '/' }) next({ path: '/user/profile' })
}) })
}) })
} else { } else {
@@ -58,6 +58,11 @@ router.beforeEach((to, from, next) => {
} }
}) })
router.afterEach(() => { router.afterEach((to) => {
NProgress.done() NProgress.done()
// 移动端不渲染 TagsView其 watch 里的 addTags 不会执行cachedViews 一直为空。
// AppMain 中 keep-alive 的 include 依赖 cachedViews空数组时部分页面如闲鱼商品首进可能白屏需刷新才正常。
if (to.name) {
store.dispatch('tagsView/addView', to)
}
}) })

View File

@@ -3,6 +3,7 @@ import auth from './auth'
import cache from './cache' import cache from './cache'
import modal from './modal' import modal from './modal'
import download from './download' import download from './download'
import request from '@/utils/request'
export default { export default {
install(Vue) { install(Vue) {
@@ -16,5 +17,7 @@ export default {
Vue.prototype.$modal = modal Vue.prototype.$modal = modal
// 下载文件 // 下载文件
Vue.prototype.$download = download Vue.prototype.$download = download
// 全局请求实例(供 this.$axios 使用)
Vue.prototype.$axios = request
} }
} }

View File

@@ -28,7 +28,7 @@ import Layout from '@/layout'
} }
*/ */
// 公共路由 // 公共路由(无需权限即可访问)
export const constantRoutes = [ export const constantRoutes = [
{ {
path: '/redirect', path: '/redirect',
@@ -62,17 +62,8 @@ export const constantRoutes = [
hidden: true hidden: true
}, },
{ {
path: '', path: '/',
component: Layout, redirect: '/sloworder/index'
redirect: 'order/list',
children: [
{
path: 'order/list',
component: () => import('@/views/system/orderrows/index'),
name: 'OrderList',
meta: { title: '京粉订单', icon: 'order', affix: true }
}
]
}, },
{ {
path: '/user', path: '/user',
@@ -88,13 +79,84 @@ export const constantRoutes = [
} }
] ]
}, },
// 公共工具首页
{
path: '/public/home',
component: () => import('@/views/public/PublicHome'),
hidden: true
},
// 评论生成工具(内部使用,不易被发现的路径)
{
path: '/tools/comment-gen',
component: () => import('@/views/public/CommentGenerator'),
hidden: true
},
// 订单搜索工具(内部使用,不易被发现的路径)
{
path: '/tools/order-search',
component: () => import('@/views/public/OrderSearch'),
hidden: true
},
// 公开订单提交页(不使用 Layout无侧边栏
{
path: '/public/order-submit',
component: () => import('@/views/public/order-submit/index'),
hidden: true
},
// 慢单管理(移到公共路由,无需权限)
{
path: '/sloworder',
component: Layout,
redirect: 'noredirect',
name: 'SlowOrder',
meta: { title: '下好的慢单', icon: 'list' },
children: [
{
path: 'index',
component: () => import('@/views/system/jdorder/orderList'),
name: 'SlowOrderIndex',
meta: { title: '下好的慢单', icon: 'list' }
}
]
},
// 移动端专用入口(隐藏菜单,底部导航直达)
{
path: '/mobile',
component: Layout,
hidden: true,
children: [
{
path: 'fadan',
component: () => import('@/views/mobile/fadan/index'),
name: 'MobileFadan',
meta: { title: '发单' }
},
{
path: 'xianyu-publish',
component: () => import('@/views/system/social-media/xianyu-wenan'),
name: 'MobileXianyuPublish',
meta: { title: '发品' }
},
{
path: 'zhongcao',
component: () => import('@/views/mobile/zhongcao/index'),
name: 'MobileZhongcao',
meta: { title: '种草' }
}
]
}
]
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
// 京粉订单管理
{ {
path: '/order', path: '/order',
component: Layout, component: Layout,
redirect: 'list', redirect: 'list',
name: 'Order', name: 'Order',
meta: { title: '京粉订单', icon: 'shopping' }, meta: { title: '京粉订单', icon: 'money' },
permissions: ['jdorder:order:list'],
children: [ children: [
{ {
path: 'list', path: 'list',
@@ -106,53 +168,26 @@ export const constantRoutes = [
path: 'statistics', path: 'statistics',
component: () => import('@/views/system/orderrows/statistics'), component: () => import('@/views/system/orderrows/statistics'),
name: 'OrderStatistics', name: 'OrderStatistics',
meta: { title: '订单统计', icon: 'chart' } meta: { title: '订单统计', icon: 'chart' },
permissions: ['jdorder:order:statistics']
}, },
{ {
path: 'settings', path: 'settings',
component: () => import('@/views/system/orderrows/settings'), component: () => import('@/views/system/orderrows/settings'),
name: 'OrderSettings', name: 'OrderSettings',
meta: { title: '订单设置', icon: 'setting' } meta: { title: '订单设置', icon: 'setting' },
} permissions: ['jdorder:order:settings']
]
},
{
path: '/message',
component: Layout,
redirect: 'noredirect',
name: 'Message',
meta: { title: '线报消息', icon: 'message' },
children: [
{
path: 'index',
component: () => import('@/views/system/xbmessage/index'),
name: 'MessageIndex',
meta: { title: '线报消息', icon: 'list' }
}
]
},
{
path: '/xbgroup',
component: Layout,
redirect: 'noredirect',
name: 'Xbgroup',
meta: { title: '线报群管理', icon: 'peoples' },
children: [
{
path: 'index',
component: () => import('@/views/system/xbgroup/index'),
name: 'XbgroupIndex',
meta: { title: '线报群列表', icon: 'list' }
} }
] ]
}, },
// 一键转链工具
{ {
path: '/jdorder', path: '/jdorder',
component: Layout, component: Layout,
redirect: 'noredirect', redirect: 'noredirect',
name: 'Jdorder', name: 'Jdorder',
meta: { title: '一键转链', icon: 'link' }, meta: { title: '一键转链', icon: 'link' },
permissions: ['jdorder:convert:list'],
children: [ children: [
{ {
path: 'index', path: 'index',
@@ -162,25 +197,153 @@ export const constantRoutes = [
} }
] ]
}, },
// 京东指令台
{
path: '/jd-instruction',
component: Layout,
redirect: 'noredirect',
name: 'JdInstruction',
meta: { title: '京东指令台', icon: 'guide' },
permissions: ['jdorder:instruction:list'],
children: [
{
path: 'index',
component: () => import('@/views/system/jd-instruction/index'),
name: 'JdInstructionIndex',
meta: { title: '指令执行', icon: 'form' }
},
{
path: 'quick-record',
component: () => import('@/views/system/jd-instruction/fadan-quick-record'),
name: 'JdInstructionQuickRecord',
meta: { title: '快捷录单', icon: 'edit' }
}
]
},
// 常用商品管理
{
path: '/favorite',
component: Layout,
redirect: 'noredirect',
name: 'Favorite',
meta: { title: '常用商品', icon: 'star' },
permissions: ['jdorder:favorite:list'],
children: [
{
path: 'index',
component: () => import('@/views/system/favoriteProduct/index'),
name: 'FavoriteIndex',
meta: { title: '常用商品', icon: 'shopping' }
}
]
},
// 线报消息管理
{
path: '/message',
component: Layout,
redirect: 'noredirect',
name: 'Message',
meta: { title: '线报消息', icon: 'message' },
permissions: ['jdorder:message:list'],
children: [
{
path: 'index',
component: () => import('@/views/system/xbmessage/index'),
name: 'MessageIndex',
meta: { title: '线报消息', icon: 'wechat' }
}
]
},
// 批量发品
{
path: '/batchPublish',
component: Layout,
redirect: 'noredirect',
name: 'BatchPublish',
meta: { title: '批量发品', icon: 'shopping' },
children: [
{
path: 'index',
component: () => import('@/views/jarvis/batchPublish/index'),
name: 'BatchPublishIndex',
meta: { title: '批量发品', icon: 'upload' }
}
]
},
// 文档同步配置
{
path: '/docSync',
component: Layout,
redirect: '/docSync/index',
name: 'DocSync',
meta: { title: '文档同步配置', icon: 'document' },
children: [
{
path: 'index',
component: () => import('@/views/jarvis/docSync/index'),
name: 'DocSyncIndex',
meta: { title: '文档同步配置', icon: 'document' }
},
{
path: 'kdocs',
component: () => import('@/views/jarvis/kdocs/index'),
name: 'KdocsCloudManage',
meta: { title: '金山文档 在线表格', icon: 'document' }
}
]
},
// 线报群管理
{
path: '/xbgroup',
component: Layout,
redirect: 'noredirect',
name: 'Xbgroup',
meta: { title: '线报群管理', icon: 'peoples' },
permissions: ['jdorder:xbgroup:list'],
children: [
{
path: 'index',
component: () => import('@/views/system/xbgroup/index'),
name: 'XbgroupIndex',
meta: { title: '线报群列表', icon: 'peoples' }
}
]
},
// 礼金管理
{
path: '/giftcoupon',
component: Layout,
redirect: 'noredirect',
name: 'GiftCoupon',
meta: { title: '礼金管理', icon: 'money' },
permissions: ['system:giftcoupon:list'],
children: [
{
path: 'index',
component: () => import('@/views/system/giftcoupon/index'),
name: 'GiftCouponIndex',
meta: { title: '礼金列表', icon: 'gift' }
}
]
},
// 系统管理
{ {
path: '/system', path: '/system',
component: Layout, component: Layout,
redirect: 'noredirect', redirect: 'noredirect',
name: 'System', name: 'System',
meta: { title: '系统管理', icon: 'system' }, meta: { title: '系统管理', icon: 'system' },
permissions: ['system:admin:list'],
children: [ children: [
{ {
path: 'superadmin', path: 'superadmin',
component: () => import('@/views/system/superadmin/index'), component: () => import('@/views/system/superadmin/index'),
name: 'Superadmin', name: 'Superadmin',
meta: { title: '超级管理员', icon: 'user' } meta: { title: '超级管理员', icon: 'people' }
} }
] ]
}, },
] // 原有的系统路由
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
{ {
path: '/system/user-auth', path: '/system/user-auth',
component: Layout, component: Layout,
@@ -237,6 +400,19 @@ export const dynamicRoutes = [
} }
] ]
}, },
{
path: '/monitor/logfile',
component: Layout,
permissions: ['monitor:server:list'],
children: [
{
path: '',
component: () => import('@/views/monitor/logfile/index'),
name: 'Logfile',
meta: { title: '日志文件', icon: 'documentation' }
}
]
},
{ {
path: '/tool/gen-edit', path: '/tool/gen-edit',
component: Layout, component: Layout,

View File

@@ -16,6 +16,7 @@ const getters = {
permission_routes: state => state.permission.routes, permission_routes: state => state.permission.routes,
topbarRouters: state => state.permission.topbarRouters, topbarRouters: state => state.permission.topbarRouters,
defaultRoutes: state => state.permission.defaultRoutes, defaultRoutes: state => state.permission.defaultRoutes,
sidebarRouters: state => state.permission.sidebarRouters sidebarRouters: state => state.permission.sidebarRouters,
favoriteProductRefreshKey: state => state.app.favoriteProductRefreshKey
} }
export default getters export default getters

View File

@@ -7,7 +7,9 @@ const state = {
hide: false hide: false
}, },
device: 'desktop', device: 'desktop',
size: Cookies.get('size') || 'medium' size: Cookies.get('size') || 'medium',
// 全局刷新标记:常用商品列表
favoriteProductRefreshKey: 0
} }
const mutations = { const mutations = {
@@ -37,6 +39,9 @@ const mutations = {
}, },
SET_SIDEBAR_HIDE: (state, status) => { SET_SIDEBAR_HIDE: (state, status) => {
state.sidebar.hide = status state.sidebar.hide = status
},
INCREMENT_FAVORITE_PRODUCT_REFRESH_KEY: (state) => {
state.favoriteProductRefreshKey++
} }
} }
@@ -55,6 +60,9 @@ const actions = {
}, },
toggleSideBarHide({ commit }, status) { toggleSideBarHide({ commit }, status) {
commit('SET_SIDEBAR_HIDE', status) commit('SET_SIDEBAR_HIDE', status)
},
triggerFavoriteProductRefresh({ commit }) {
commit('INCREMENT_FAVORITE_PRODUCT_REFRESH_KEY')
} }
} }

230
src/utils/mobile.js Normal file
View File

@@ -0,0 +1,230 @@
/**
* 移动端工具函数
*/
/**
* 检测是否为移动设备
*/
export function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth < 768
}
/**
* 检测是否为iOS设备
*/
export function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent)
}
/**
* 检测是否为Android设备
*/
export function isAndroid() {
return /Android/.test(navigator.userAgent)
}
/**
* 获取设备类型
*/
export function getDeviceType() {
if (isMobile()) {
if (isIOS()) return 'ios'
if (isAndroid()) return 'android'
return 'mobile'
}
return 'desktop'
}
/**
* 设置移动端视口
*/
export function setMobileViewport() {
if (isMobile()) {
const viewport = document.querySelector('meta[name="viewport"]')
if (viewport) {
viewport.setAttribute('content', 'width=device-width, initial-scale=1, maximum-scale=5, minimum-scale=1, user-scalable=yes, viewport-fit=cover')
}
}
}
/**
* 防止iOS双击缩放
*/
export function preventDoubleTapZoom() {
if (isIOS()) {
let lastTouchEnd = 0
document.addEventListener('touchend', (event) => {
const now = Date.now()
if (now - lastTouchEnd <= 300) {
event.preventDefault()
}
lastTouchEnd = now
}, false)
}
}
/**
* 优化移动端滚动
*/
export function optimizeMobileScroll() {
if (isMobile()) {
// 添加平滑滚动
document.documentElement.style.scrollBehavior = 'smooth'
// 优化触摸滚动
const style = document.createElement('style')
style.textContent = `
* {
-webkit-overflow-scrolling: touch;
}
`
document.head.appendChild(style)
}
}
/**
* 设置安全区域适配iOS刘海屏
*/
export function setSafeArea() {
if (isIOS()) {
const style = document.createElement('style')
style.textContent = `
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-left {
padding-left: env(safe-area-inset-left);
}
.safe-area-right {
padding-right: env(safe-area-inset-right);
}
`
document.head.appendChild(style)
}
}
/**
* 移动端初始化
*/
export function initMobile() {
setMobileViewport()
preventDoubleTapZoom()
optimizeMobileScroll()
setSafeArea()
}
/**
* 格式化移动端表格数据为卡片数据
*/
export function formatTableToCards(tableData, columns) {
return tableData.map(row => {
const card = {}
columns.forEach(column => {
if (column.visible !== false) {
const value = column.prop ? getNestedValue(row, column.prop) : ''
card[column.prop || column.key] = {
label: column.label,
value: column.formatter ? column.formatter(row, column, value) : value,
type: column.type || 'text'
}
}
})
return {
...row,
_cardData: card
}
})
}
/**
* 获取嵌套对象值
*/
function getNestedValue(obj, path) {
return path.split('.').reduce((o, p) => o && o[p], obj)
}
/**
* 移动端表格列配置
*/
export function getMobileTableColumns(columns) {
return columns
.filter(col => col.visible !== false)
.map(col => ({
...col,
label: col.label || col.prop,
prop: col.prop || col.key
}))
}
/**
* 防抖函数
*/
export function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
/**
* 节流函数
*/
export function throttle(func, limit) {
let inThrottle
return function(...args) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
/**
* 移动端图片懒加载
*/
export function lazyLoadImages() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
img.classList.remove('lazy')
imageObserver.unobserve(img)
}
})
})
document.querySelectorAll('img.lazy').forEach(img => {
imageObserver.observe(img)
})
}
}
export default {
isMobile,
isIOS,
isAndroid,
getDeviceType,
setMobileViewport,
preventDoubleTapZoom,
optimizeMobileScroll,
setSafeArea,
initMobile,
formatTableToCards,
getMobileTableColumns,
debounce,
throttle,
lazyLoadImages
}

View File

@@ -0,0 +1,97 @@
import { addToFavorites, getBySkuid } from "@/api/system/favoriteProduct";
function parseMaybeJson(str) {
if (!str || typeof str !== 'string') return null;
try { return JSON.parse(str); } catch(e) { return null; }
}
function getNested(obj, path) {
try {
if (!obj) return null;
const segs = path.split('.');
let cur = obj;
for (const k of segs) {
if (cur && typeof cur === 'object' && k in cur) cur = cur[k]; else return null;
}
return cur;
} catch(e) { return null; }
}
export function parsePublishResponse(res) {
try {
const d = res && res.data ? res.data : res;
const productId = d?.product_id ?? d?.productId ?? d?.data?.product_id ?? d?.data?.productId ?? null;
const productStatus = d?.product_status ?? d?.productStatus ?? d?.data?.product_status ?? d?.data?.productStatus ?? null;
const outerId = d?.outer_id ?? d?.outerId ?? d?.data?.outer_id ?? d?.data?.outerId ?? null;
return { productId, productStatus, outerId };
} catch(e) { return { productId: null, productStatus: null, outerId: null }; }
}
function buildFavoriteFromTransfer(product, pub) {
const spuid = product?.spuid || product?.skuId || product?.skuid || '';
return {
skuid: spuid,
productName: product?.skuName || product?.title || '',
shopName: product?.shopName || '',
productUrl: product?.url || '',
productImage: Array.isArray(product?.images) && product.images.length ? product.images[0] : '',
price: (product?.price != null ? String(product.price) : (product?.lowestCouponPrice != null ? String(product.lowestCouponPrice) : '')),
commissionInfo: product?.commissionShare ? `${product.commissionShare}%` : (product?.commission != null ? String(product.commission) : ''),
remark: `自动添加 - 发品时间: ${new Date().toLocaleString()}${pub.outerId ? `, 商家编码: ${pub.outerId}` : ''}`,
productId: pub.productId,
productStatus: pub.productStatus
};
}
function buildFavoriteFromXb(child, pub) {
const jq = parseMaybeJson(child?.jsonQueryResult);
const spuid = (jq && (jq.spuid || getNested(jq, 'spuid'))) || child?.spuid || child?.skuid || '';
const shopName = getNested(jq, 'shopInfo.shopName') || child?.shopName || '';
const shopId = getNested(jq, 'shopInfo.shopId') || child?.shopId || '';
const productUrl = jq?.materialUrl || jq?.url || child?.materialUrl || child?.productUrl || '';
const productImage = getNested(jq, 'imageInfo.mainImage') || child?.productImage || '';
const price = child?.firstPrice || getNested(jq, 'priceInfo.lowestCouponPrice') || getNested(jq, 'priceInfo.price') || '';
const commissionInfo = getNested(jq, 'commissionInfo.commissionShare') ? `${getNested(jq, 'commissionInfo.commissionShare')}%` : (getNested(jq, 'commissionInfo.commission') || '');
return {
skuid: spuid,
productName: child?.skuName || child?.productName || '',
shopName,
shopId,
productUrl,
productImage,
price,
commissionInfo,
productId: pub.productId,
productStatus: pub.productStatus,
remark: `自动添加 - 发品时间: ${new Date().toLocaleString()}${pub.outerId ? `, 商家编码: ${pub.outerId}` : ''}`
};
}
export async function addToFavoritesAfterPublishFromTransfer(product, res) {
const pub = parsePublishResponse(res);
const spuid = product?.spuid || product?.skuId || product?.skuid || '';
if (!spuid) return { success: false, reason: 'no_spuid' };
try {
const exist = await getBySkuid(spuid);
if (exist && exist.data) return { success: true, skipped: true };
} catch(e) {}
const payload = buildFavoriteFromTransfer(product, pub);
const addRes = await addToFavorites(payload);
return { success: addRes && addRes.code === 200 };
}
export async function addToFavoritesAfterPublishFromXb(child, res) {
const pub = parsePublishResponse(res);
const jq = parseMaybeJson(child?.jsonQueryResult);
const spuid = (jq && (jq.spuid || getNested(jq, 'spuid'))) || child?.spuid || child?.skuid || '';
if (!spuid) return { success: false, reason: 'no_spuid' };
try {
const exist = await getBySkuid(spuid);
if (exist && exist.data) return { success: true, skipped: true };
} catch(e) {}
const payload = buildFavoriteFromXb(child, pub);
const addRes = await addToFavorites(payload);
return { success: addRes && addRes.code === 200 };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
<template>
<div class="kdocs-config">
<div class="config-container">
<div class="config-left">
<el-card class="config-section">
<div slot="header" class="section-header">
<i class="el-icon-key"></i>
<span>授权状态</span>
</div>
<div class="auth-status">
<el-tag v-if="isAuthorized" type="success" size="medium">
<i class="el-icon-circle-check"></i> 已授权
</el-tag>
<el-tag v-else type="danger" size="medium">
<i class="el-icon-circle-close"></i> 未授权
</el-tag>
<el-button
v-if="!isAuthorized"
type="primary"
size="small"
icon="el-icon-unlock"
@click="handleAuthorize"
style="margin-left: 10px;"
>立即授权</el-button>
<el-button
v-else
type="info"
size="small"
icon="el-icon-refresh"
@click="handleRefreshAuth"
style="margin-left: 10px;"
>刷新状态</el-button>
</div>
<div v-if="isAuthorized && tokenInfo" class="token-info" style="margin-top: 15px;">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户ID">{{ tokenInfo.userId || '-' }}</el-descriptions-item>
<el-descriptions-item label="Token状态">
<el-tag v-if="tokenInfo.isValid" type="success" size="small">有效</el-tag>
<el-tag v-else type="warning" size="small">已过期</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="tokenInfo.expiresIn" label="有效期">
{{ Math.floor(tokenInfo.expiresIn / 60) }} 分钟
</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
<el-card class="config-section">
<div slot="header" class="section-header">
<i class="el-icon-document"></i>
<span>H-TF订单自动写入配置</span>
<el-tag v-if="config.isConfigured" type="success" size="mini" style="margin-left: 10px;">已配置</el-tag>
<el-tag v-else type="warning" size="mini" style="margin-left: 10px;">未配置</el-tag>
</div>
<el-form ref="form" :model="form" :rules="rules" label-width="120px" size="small">
<el-form-item label="file_token" prop="fileId">
<el-input
v-model="form.fileId"
placeholder="个人云文档列表接口返回的 file_token文档 open_id"
clearable
>
<el-button
slot="append"
icon="el-icon-search"
:disabled="!form.fileId || !isAuthorized"
@click="handleTestRead"
>测试读取</el-button>
</el-input>
</el-form-item>
<el-form-item label="工作表 sheet_idx">
<el-input-number v-model="form.sheetIdx" :min="0" controls-position="right" style="width: 100%;" />
</el-form-item>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="表头行号" prop="headerRow">
<el-input-number v-model="form.headerRow" :min="1" controls-position="right" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="起始行号" prop="startRow">
<el-input-number v-model="form.startRow" :min="1" controls-position="right" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button type="primary" :loading="saveLoading" icon="el-icon-check" @click="handleSave">保存配置</el-button>
<el-button :loading="testLoading" icon="el-icon-setting" @click="handleTest">测试配置</el-button>
<el-button type="danger" plain :loading="clearLoading" icon="el-icon-delete" @click="handleClear">清除配置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
<div class="config-right">
<el-card class="status-card-wrapper">
<div class="status-card" :class="config.isConfigured ? 'success' : 'warning'">
<div class="status-icon" :class="config.isConfigured ? 'success' : 'warning'">
<i :class="config.isConfigured ? 'el-icon-success' : 'el-icon-warning'"></i>
</div>
<div class="status-text">
<div class="status-title">{{ config.isConfigured ? '配置完成' : '配置未完成' }}</div>
<div class="status-desc">
{{ config.hint || (config.isConfigured ? 'H-TF订单将写入金山文档在线表格工作表' : '请完成配置') }}
</div>
</div>
</div>
</el-card>
<el-card class="help-card-wrapper">
<div slot="header" class="card-header">
<i class="el-icon-question"></i>
<span>说明</span>
</div>
<div class="help-content">
<div class="help-item"><i class="el-icon-check"></i><span>file_token 来自在线表格管理文件列表或开放平台文档列表</span></div>
<div class="help-item"><i class="el-icon-check"></i><span>工作表读写使用 KSheet 单元格接口sheet_idx 与后台 sheet 一致</span></div>
<div class="help-item"><i class="el-icon-check"></i><span>数据表db需改用数据表记录类 API本页为工作表et场景</span></div>
</div>
</el-card>
</div>
</div>
</div>
</template>
<script>
import {
getKdocsAuthUrl,
getKdocsTokenStatus,
readKdocsCells,
updateKdocsCells
} from '@/api/jarvis/kdocs'
const LS_KEY = 'kdocs_auto_write_config'
export default {
name: 'KdocsCloudConfig',
data() {
return {
isAuthorized: false,
userId: 'default_user',
tokenInfo: null,
config: { isConfigured: false, hint: '' },
form: {
fileId: '',
sheetIdx: 0,
headerRow: 2,
startRow: 3
},
rules: {
fileId: [{ required: true, message: '请输入 file_token', trigger: 'blur' }],
headerRow: [
{ required: true, message: '请输入表头行号', trigger: 'blur' },
{ type: 'number', min: 1, message: '表头行号必须大于0', trigger: 'blur' }
],
startRow: [
{ required: true, message: '请输入数据起始行', trigger: 'blur' },
{ type: 'number', min: 1, message: '数据起始行必须大于0', trigger: 'blur' }
]
},
saveLoading: false,
testLoading: false,
clearLoading: false
}
},
created() {
this.checkAuthStatus()
this.loadConfig()
},
methods: {
refresh() {
this.checkAuthStatus()
this.loadConfig()
},
async checkAuthStatus() {
try {
const response = await getKdocsTokenStatus(this.userId)
if (response.code === 200) {
// 有 token 即视为已授权isValid 单独展示(避免 expires_in 缺失时误判未保存)
this.isAuthorized = !!response.data.hasToken
this.tokenInfo = response.data
if (response.data.userId) {
this.userId = response.data.userId
}
}
} catch (e) {
console.error(e)
}
},
loadConfig() {
try {
const raw = localStorage.getItem(LS_KEY)
if (raw) {
const c = JSON.parse(raw)
this.form.fileId = c.fileId || ''
this.form.sheetIdx = c.sheetIdx != null ? c.sheetIdx : 0
this.form.headerRow = c.headerRow || 2
this.form.startRow = c.startRow || 3
this.config.isConfigured = !!c.fileId
}
} catch (e) {
console.error(e)
}
},
extractValues(payload) {
if (!payload) return []
if (Array.isArray(payload.values)) return payload.values
if (payload.data && Array.isArray(payload.data.values)) return payload.data.values
return []
},
async handleAuthorize() {
try {
const response = await getKdocsAuthUrl()
if (response.code === 200) {
const w = 600
const h = 700
window.open(
response.data,
'KdocsAuth',
`width=${w},height=${h},left=${(screen.width - w) / 2},top=${(screen.height - h) / 2},resizable=yes,scrollbars=yes`
)
this.$message.success('请在弹出窗口完成授权')
const handler = (event) => {
if (event.data && event.data.type === 'kdocs_oauth_callback') {
window.removeEventListener('message', handler)
if (event.data.userId) {
this.userId = event.data.userId
}
setTimeout(() => {
this.checkAuthStatus()
this.$message.success('授权完成')
}, 500)
}
}
window.addEventListener('message', handler)
setTimeout(() => this.checkAuthStatus(), 3000)
}
} catch (e) {
this.$message.error(e.msg || e.message || '获取授权地址失败')
}
},
async handleRefreshAuth() {
await this.checkAuthStatus()
this.$message.success('已刷新')
},
async handleTestRead() {
if (!this.isAuthorized) {
this.$message.warning('请先授权')
return
}
if (!this.form.fileId) {
this.$message.warning('请输入 file_token')
return
}
try {
const response = await readKdocsCells({
userId: this.userId,
fileToken: this.form.fileId,
sheetIdx: this.form.sheetIdx,
range: 'A1:B5'
})
if (response.code === 200) {
this.$message.success('读取成功')
console.log('read', response.data)
} else {
this.$message.warning(response.msg || '读取失败')
}
} catch (e) {
this.$message.error(e.msg || e.message || '读取失败')
}
},
handleSave() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
if (!this.isAuthorized) {
this.$message.warning('请先授权')
return
}
this.saveLoading = true
try {
localStorage.setItem(
LS_KEY,
JSON.stringify({
fileId: this.form.fileId,
sheetIdx: this.form.sheetIdx,
headerRow: this.form.headerRow,
startRow: this.form.startRow
})
)
this.config.isConfigured = true
this.config.hint = '将使用金山文档 KSheet 写入工作表'
this.$message.success('已保存')
} finally {
this.saveLoading = false
}
})
},
handleTest() {
if (!this.isAuthorized) {
this.$message.warning('请先授权')
return
}
this.$refs.form.validate(async (valid) => {
if (!valid) return
this.testLoading = true
try {
const readRes = await readKdocsCells({
userId: this.userId,
fileToken: this.form.fileId,
sheetIdx: this.form.sheetIdx,
range: 'A1:B5'
})
if (readRes.code !== 200) {
this.$message.error(readRes.msg || '读失败')
return
}
const testRange = `A${this.form.startRow}:B${this.form.startRow}`
const writeRes = await updateKdocsCells({
userId: this.userId,
fileToken: this.form.fileId,
sheetIdx: this.form.sheetIdx,
range: testRange,
values: [['测试1', '测试2']]
})
if (writeRes.code === 200) {
this.$message.success('读写测试成功')
} else {
this.$message.warning(writeRes.msg || '写失败')
}
} catch (e) {
this.$message.error(e.msg || e.message || '测试失败')
} finally {
this.testLoading = false
}
})
},
handleClear() {
this.$confirm('确定清除本地配置?', '提示', { type: 'warning' })
.then(() => {
localStorage.removeItem(LS_KEY)
this.form.fileId = ''
this.form.sheetIdx = 0
this.form.headerRow = 2
this.form.startRow = 3
this.config.isConfigured = false
this.config.hint = ''
this.$message.success('已清除')
})
.catch(() => {})
}
}
}
</script>
<style scoped>
.kdocs-config {
padding: 0;
}
.config-container {
display: flex;
gap: 20px;
}
.config-left {
flex: 1;
min-width: 0;
}
.config-right {
width: 300px;
flex-shrink: 0;
}
.config-section {
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
}
.auth-status {
display: flex;
align-items: center;
}
.status-card-wrapper {
margin-bottom: 20px;
}
.status-card {
display: flex;
align-items: center;
padding: 15px;
border-radius: 4px;
}
.status-card.success {
background-color: #f0f9ff;
border: 1px solid #b3d8ff;
}
.status-card.warning {
background-color: #fef0f0;
border: 1px solid #fbc4c4;
}
.status-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-right: 15px;
}
.status-icon.success {
background-color: #67c23a;
color: white;
}
.status-icon.warning {
background-color: #e6a23c;
color: white;
}
.status-text {
flex: 1;
}
.status-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.status-desc {
font-size: 12px;
color: #909399;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.help-item {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
font-size: 13px;
color: #606266;
}
.help-item i {
color: #67c23a;
margin-top: 3px;
}
</style>

View File

@@ -0,0 +1,723 @@
<template>
<div class="tendoc-config">
<div class="config-container">
<!-- 左侧配置表单 -->
<div class="config-left">
<!-- 授权状态 -->
<el-card class="config-section">
<div slot="header" class="section-header">
<i class="el-icon-key"></i>
<span>授权状态</span>
</div>
<div class="auth-status">
<el-tag v-if="config.hasAccessToken" type="success" size="medium">
<i class="el-icon-circle-check"></i> {{ config.accessTokenStatus }}
</el-tag>
<el-tag v-else type="danger" size="medium">
<i class="el-icon-circle-close"></i> {{ config.accessTokenStatus }}
</el-tag>
<el-button
v-if="!config.hasAccessToken"
type="primary"
size="small"
icon="el-icon-unlock"
@click="handleAuth"
style="margin-left: 10px;"
>
立即授权
</el-button>
<el-button
v-else
type="info"
size="small"
icon="el-icon-refresh"
@click="handleRefreshAuth"
style="margin-left: 10px;"
>
刷新状态
</el-button>
</div>
<div v-if="config.hint" class="config-hint" style="margin-top: 10px; color: #909399; font-size: 12px;">
<i class="el-icon-info"></i> {{ config.hint }}
</div>
</el-card>
<!-- 文档配置表单 -->
<el-card class="config-section">
<div slot="header" class="section-header">
<i class="el-icon-document"></i>
<span>H-TF订单自动写入配置</span>
<el-tag v-if="config.isConfigured" type="success" size="mini" style="margin-left: 10px;">
<i class="el-icon-success"></i> 已配置
</el-tag>
<el-tag v-else type="warning" size="mini" style="margin-left: 10px;">
<i class="el-icon-warning"></i> 未配置
</el-tag>
</div>
<el-form ref="form" :model="form" :rules="rules" label-width="100px" size="small">
<el-form-item label="文件ID" prop="fileId">
<el-input
v-model="form.fileId"
placeholder="例如DUW50RUprWXh2TGJK"
clearable
>
<el-button
slot="append"
icon="el-icon-search"
@click="handleFetchSheets"
:disabled="!form.fileId"
>
获取工作表
</el-button>
</el-input>
</el-form-item>
<el-form-item label="工作表ID" prop="sheetId">
<el-select
v-if="sheetList.length > 0"
v-model="form.sheetId"
placeholder="请选择工作表"
style="width: 100%;"
clearable
>
<el-option
v-for="sheet in sheetList"
:key="sheet.sheetId"
:label="sheet.title"
:value="sheet.sheetId"
>
<span style="float: left">{{ sheet.title }}</span>
<span style="float: right; color: #8492a6; font-size: 12px;">{{ sheet.sheetId }}</span>
</el-option>
</el-select>
<el-input
v-else
v-model="form.sheetId"
placeholder="例如BB08J2"
clearable
/>
</el-form-item>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="表头行号" prop="headerRow">
<el-input-number
v-model="form.headerRow"
:min="1"
controls-position="right"
style="width: 100%;"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="起始行号" prop="startRow">
<el-input-number
v-model="form.startRow"
:min="1"
controls-position="right"
style="width: 100%;"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button type="primary" @click="handleSave" :loading="saveLoading" icon="el-icon-check">
保存配置
</el-button>
<el-button @click="handleTest" :loading="testLoading" icon="el-icon-setting">
测试配置
</el-button>
<el-button @click="handleClear" :loading="clearLoading" type="danger" plain icon="el-icon-delete">
清除配置
</el-button>
<el-button @click="showOperationLogs = true" icon="el-icon-document">
操作日志
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 右侧状态信息 -->
<div class="config-right">
<!-- 配置状态提示 -->
<el-card class="status-card-wrapper">
<div class="status-card" :class="config.isConfigured ? 'success' : 'warning'">
<div class="status-icon" :class="config.isConfigured ? 'success' : 'warning'">
<i :class="config.isConfigured ? 'el-icon-success' : 'el-icon-warning'"></i>
</div>
<div class="status-text">
<div class="status-title">{{ config.isConfigured ? '配置完成' : '配置未完成' }}</div>
<div class="status-desc">{{ config.hint || (config.isConfigured ? 'H-TF订单将自动写入腾讯文档' : '请完成配置') }}</div>
</div>
</div>
</el-card>
<!-- 表格行数从接口获取用于决定同步范围 -->
<el-card v-if="config.progressHint || config.currentProgress" class="progress-card-wrapper">
<div slot="header" class="card-header">
<i class="el-icon-data-line"></i>
<span>表格行数</span>
</div>
<div class="progress-content">
<div v-if="config.currentProgress" class="progress-detail">
<div class="progress-item">
<span class="label">当前有数据行数</span>
<span class="value"> {{ config.currentProgress }} 接口获取</span>
</div>
<div class="progress-item">
<span class="label">下次同步起始</span>
<span class="value"> {{ config.nextStartRow != null ? config.nextStartRow : form.startRow }} </span>
</div>
<div class="progress-hint">
<i class="el-icon-info"></i>
由接口实时获取表格行数不再使用本地保存的进度
</div>
</div>
<div v-else class="no-progress">
{{ config.progressHint || '暂无表格行数(请先授权并配置)' }}
</div>
</div>
</el-card>
<!-- 快速帮助 -->
<el-card class="help-card-wrapper">
<div slot="header" class="card-header">
<i class="el-icon-question"></i>
<span>配置说明</span>
</div>
<div class="help-content">
<div class="help-item">
<i class="el-icon-check"></i>
<span>文件ID从腾讯文档URL中获取</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>点击"获取工作表"自动加载</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>表头行号默认为第2行</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>数据起始行默认为第3行</span>
</div>
</div>
</el-card>
</div>
</div>
<!-- 操作日志查看对话框 -->
<tencent-doc-operation-logs
v-model="showOperationLogs"
:file-id="form.fileId"
:sheet-id="form.sheetId"
/>
</div>
</template>
<script>
import {
getAutoWriteConfig,
updateAutoWriteConfig,
testAutoWriteConfig,
clearAutoWriteConfig,
getDocSheetList,
getTencentDocAuthUrl
} from '@/api/jarvis/tendoc'
import TencentDocOperationLogs from '@/views/system/jdorder/components/TencentDocOperationLogs'
export default {
name: 'TencentDocConfig',
components: {
TencentDocOperationLogs
},
data() {
return {
showOperationLogs: false,
config: {
hasAccessToken: false,
accessTokenStatus: '未授权',
fileId: '',
sheetId: '',
appId: '',
apiBaseUrl: '',
isConfigured: false,
hint: '',
progressHint: '',
currentProgress: null
},
form: {
fileId: '',
sheetId: '',
headerRow: 2,
startRow: 3
},
rules: {
fileId: [
{ required: true, message: '请输入文件ID', trigger: 'blur' }
],
sheetId: [
{ required: true, message: '请输入工作表ID', trigger: 'blur' }
],
headerRow: [
{ required: true, message: '请输入表头行号', trigger: 'blur' },
{ type: 'number', min: 1, message: '表头行号必须大于0', trigger: 'blur' }
],
startRow: [
{ required: true, message: '请输入数据起始行', trigger: 'blur' },
{ type: 'number', min: 1, message: '数据起始行必须大于0', trigger: 'blur' }
]
},
sheetList: [],
saveLoading: false,
testLoading: false,
clearLoading: false
}
},
created() {
this.loadConfig()
},
methods: {
/** 刷新配置 */
refresh() {
this.loadConfig()
},
/** 加载当前配置 */
async loadConfig() {
try {
const res = await getAutoWriteConfig()
if (res.code === 200 && res.data) {
this.config = res.data
this.form.fileId = res.data.fileId || ''
this.form.sheetId = res.data.sheetId || ''
// 确保 headerRow 和 startRow 是数字类型
this.form.headerRow = parseInt(res.data.headerRow) || 2
this.form.startRow = parseInt(res.data.startRow) || 3
console.log('配置加载成功 - headerRow:', this.form.headerRow, 'startRow:', this.form.startRow)
}
} catch (e) {
this.$message.error('加载配置失败:' + (e.message || '未知错误'))
}
},
/** 打开授权页面 */
async handleAuth() {
try {
const res = await getTencentDocAuthUrl()
if (res.code !== 200 || !res.data) {
this.$message.error('获取授权URL失败')
return
}
const authUrl = res.data
const width = 600
const height = 700
const left = (window.screen.width - width) / 2
const top = (window.screen.height - height) / 2
window.open(
authUrl,
'腾讯文档授权',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
)
this.$message.success('授权页面已打开,请在新窗口中完成授权')
// 监听授权完成消息
const messageHandler = (event) => {
if (event.data && event.data.type === 'tendoc_oauth_callback') {
window.removeEventListener('message', messageHandler)
this.loadConfig()
this.$message.success('授权完成')
}
}
window.addEventListener('message', messageHandler)
// 3秒后刷新配置状态
setTimeout(() => {
this.loadConfig()
}, 3000)
} catch (e) {
this.$message.error('打开授权页面失败:' + (e.message || '未知错误'))
}
},
/** 刷新授权状态 */
async handleRefreshAuth() {
await this.loadConfig()
this.$message.success('授权状态已刷新')
},
/** 获取工作表列表 */
async handleFetchSheets() {
if (!this.form.fileId) {
this.$message.warning('请先输入文件ID')
return
}
try {
this.$message.info('正在获取工作表列表...')
const res = await getDocSheetList(this.form.fileId)
if (res.code === 200 && res.data && res.data.sheets) {
this.sheetList = res.data.sheets
this.$message.success(`获取成功,共 ${this.sheetList.length} 个工作表`)
} else {
this.$message.error('获取工作表列表失败:' + (res.msg || '未知错误'))
}
} catch (e) {
this.$message.error('获取工作表列表失败:' + (e.message || '未知错误'))
}
},
/** 保存配置 */
handleSave() {
this.$refs.form.validate(async valid => {
if (!valid) {
return
}
this.saveLoading = true
try {
const res = await updateAutoWriteConfig({
fileId: this.form.fileId,
sheetId: this.form.sheetId,
headerRow: this.form.headerRow,
startRow: this.form.startRow
})
if (res.code === 200) {
this.$message.success(`配置保存成功!表头第${this.form.headerRow}行,数据从第${this.form.startRow}行开始`)
console.log('配置保存成功 - 保存的值:', {
fileId: this.form.fileId,
sheetId: this.form.sheetId,
headerRow: this.form.headerRow,
startRow: this.form.startRow
})
// 延迟重新加载配置,确保后端已保存
setTimeout(() => {
this.loadConfig()
}, 500)
} else {
this.$message.error('保存失败:' + (res.msg || '未知错误'))
}
} catch (e) {
this.$message.error('保存失败:' + (e.message || '未知错误'))
} finally {
this.saveLoading = false
}
})
},
/** 测试配置 */
async handleTest() {
this.testLoading = true
try {
const res = await testAutoWriteConfig()
if (res.code === 200) {
this.$alert(
'<pre style="text-align: left; max-height: 400px; overflow: auto;">' +
JSON.stringify(res.data, null, 2) +
'</pre>',
'测试成功',
{
dangerouslyUseHTMLString: true,
confirmButtonText: '确定',
type: 'success'
}
)
} else {
this.$message.error('测试失败:' + (res.msg || '未知错误'))
}
} catch (e) {
this.$message.error('测试失败:' + (e.message || '未知错误'))
} finally {
this.testLoading = false
}
},
/** 清除配置 */
async handleClear() {
try {
await this.$confirm('确定要清除配置吗?这不会清除授权令牌。', '提示', {
type: 'warning'
})
this.clearLoading = true
const res = await clearAutoWriteConfig()
if (res.code === 200) {
this.$message.success('配置已清除')
this.form.fileId = ''
this.form.sheetId = ''
this.form.startRow = 3
this.sheetList = []
this.loadConfig()
} else {
this.$message.error('清除失败:' + (res.msg || '未知错误'))
}
} catch (e) {
if (e !== 'cancel') {
this.$message.error('清除失败:' + (e.message || '未知错误'))
}
} finally {
this.clearLoading = false
}
}
}
}
</script>
<style scoped>
/* 容器布局 */
.config-container {
display: flex;
gap: 20px;
min-height: 400px;
}
.config-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 15px;
}
.config-right {
width: 300px;
display: flex;
flex-direction: column;
gap: 15px;
}
/* 配置区块 */
.config-section {
background: #f5f7fa;
border-radius: 6px;
}
.section-header {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #303133;
}
.section-header i {
margin-right: 6px;
font-size: 16px;
color: #409eff;
}
/* 授权状态 */
.auth-status {
display: flex;
align-items: center;
gap: 10px;
}
.config-hint {
margin-top: 10px;
color: #909399;
font-size: 12px;
}
/* 状态卡片 */
.status-card-wrapper {
padding: 0;
border: none;
box-shadow: none;
}
.status-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 20px;
color: white;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.3);
}
.status-card.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.status-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.status-icon.success {
background: rgba(103, 194, 58, 0.2);
}
.status-icon.warning {
background: rgba(230, 162, 60, 0.2);
}
.status-text {
flex: 1;
}
.status-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
}
.status-desc {
font-size: 12px;
opacity: 0.9;
line-height: 1.5;
}
/* 进度卡片 */
.progress-card-wrapper {
padding: 0;
border: none;
box-shadow: none;
}
.progress-card-wrapper >>> .el-card__body {
padding: 0;
}
.card-header {
background: #f5f7fa;
padding: 12px 15px;
font-size: 14px;
font-weight: 500;
color: #303133;
display: flex;
align-items: center;
border-bottom: 1px solid #e4e7ed;
}
.card-header i {
margin-right: 6px;
color: #409eff;
}
.progress-content {
padding: 15px;
}
.progress-detail {
display: flex;
flex-direction: column;
gap: 10px;
}
.progress-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f0f9ff;
border-radius: 4px;
border-left: 3px solid #409eff;
}
.progress-item .label {
font-size: 13px;
color: #606266;
}
.progress-item .value {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.progress-hint {
font-size: 12px;
color: #909399;
padding: 8px 12px;
background: #fef0f0;
border-radius: 4px;
border-left: 3px solid #f56c6c;
display: flex;
align-items: center;
gap: 5px;
}
.no-progress {
font-size: 13px;
color: #909399;
text-align: center;
padding: 10px;
}
/* 帮助卡片 */
.help-card-wrapper {
padding: 0;
border: none;
box-shadow: none;
}
.help-card-wrapper >>> .el-card__body {
padding: 0;
}
.help-content {
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.help-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.help-item i {
color: #67c23a;
margin-top: 2px;
flex-shrink: 0;
}
/* Element UI 覆盖样式 */
.config-section >>> .el-form-item {
margin-bottom: 18px;
}
.config-section >>> .el-form-item__label {
font-weight: 500;
color: #606266;
}
.config-section >>> .el-input-number {
width: 100%;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.config-container {
flex-direction: column;
}
.config-right {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="app-container">
<el-card>
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 16px;">
<i class="el-icon-document"></i> 文档同步配置
</span>
</div>
<!-- Tab切换 -->
<el-tabs v-model="activeTab" type="card" @tab-click="handleTabClick">
<el-tab-pane label="腾讯文档" name="tendoc">
<span slot="label">
<i class="el-icon-document"></i> 腾讯文档
</span>
<TencentDocConfig ref="tendocConfig" />
</el-tab-pane>
<el-tab-pane label="金山文档" name="kdocs">
<span slot="label">
<i class="el-icon-document-copy"></i> 金山文档
</span>
<KdocsCloudConfig ref="kdocsConfig" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script>
import TencentDocConfig from './components/TencentDocConfig'
import KdocsCloudConfig from './components/KdocsCloudConfig'
export default {
name: 'DocSync',
components: {
TencentDocConfig,
KdocsCloudConfig
},
data() {
return {
activeTab: 'tendoc'
}
},
methods: {
handleTabClick(tab) {
// Tab切换时的处理
this.$nextTick(() => {
if (tab.name === 'tendoc' && this.$refs.tendocConfig) {
this.$refs.tendocConfig.refresh()
} else if (tab.name === 'kdocs' && this.$refs.kdocsConfig) {
this.$refs.kdocsConfig.refresh()
}
})
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
::v-deep .el-tabs__header {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,325 @@
<template>
<div class="app-container">
<el-card>
<div slot="header" class="clearfix">
<span>金山文档 在线表格</span>
<el-button style="float: right; padding: 3px 0" type="text" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<el-alert v-if="!isAuthorized" title="未授权" type="warning" :closable="false" show-icon>
<template slot="default">
<span>请完成金山文档开放平台授权个人云</span>
<el-button type="primary" size="small" style="margin-left: 10px" @click="handleAuthorize">立即授权</el-button>
</template>
</el-alert>
<el-alert v-else title="已授权" type="success" :closable="false" show-icon>
<template slot="default">
<span>授权状态正常</span>
</template>
</el-alert>
<el-card v-if="isAuthorized && userInfo" style="margin-top: 20px">
<div slot="header">用户信息</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="open_id">{{ userInfo.user_id || userInfo.open_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ userInfo.nickname || userInfo.name || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card v-if="isAuthorized" style="margin-top: 20px">
<div slot="header">
<span>文件列表个人云扁平列表</span>
<el-button type="primary" size="small" style="float: right" @click="handleLoadFiles(true)">加载 / 刷新</el-button>
</div>
<p v-if="listHint" class="list-hint">{{ listHint }}</p>
<el-table v-loading="fileListLoading" :data="fileList" border style="width: 100%">
<el-table-column prop="file_name" label="文件名" min-width="200" />
<el-table-column prop="file_token" label="file_token" min-width="260" show-overflow-tooltip />
<el-table-column prop="file_type" label="类型" width="100" />
<el-table-column label="操作" width="200">
<template slot-scope="scope">
<el-button type="success" size="mini" @click="handleEditFile(scope.row)">编辑表格</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="hasMoreFiles" style="margin-top: 12px">
<el-button type="default" size="small" :loading="fileListLoading" @click="handleLoadMore">加载更多</el-button>
</div>
</el-card>
<el-dialog title="编辑表格" :visible.sync="editDialogVisible" width="80%" :close-on-click-modal="false">
<div v-if="currentFile">
<el-form :inline="true" class="demo-form-inline">
<el-form-item label="工作表">
<el-select v-model="currentSheetIdx" placeholder="请选择" @change="handleLoadSheetData">
<el-option
v-for="sheet in sheetList"
:key="sheet.sheet_idx + '-' + sheet.name"
:label="sheet.name + ' (idx:' + sheet.sheet_idx + ')'"
:value="sheet.sheet_idx"
/>
</el-select>
</el-form-item>
<el-form-item label="单元格范围">
<el-input v-model="cellRange" placeholder="例如A1:B10" style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLoadSheetData">读取数据</el-button>
<el-button type="success" @click="handleUpdateCells">保存数据</el-button>
</el-form-item>
</el-form>
<el-table v-loading="sheetDataLoading" :data="sheetData" border style="width: 100%; margin-top: 20px">
<el-table-column
v-for="(col, index) in sheetColumns"
:key="index"
:prop="'col' + index"
:label="getColumnLabel(index)"
min-width="120"
>
<template slot-scope="scope">
<el-input v-model="scope.row['col' + index]" size="small" />
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
</el-card>
</div>
</template>
<script>
import {
getKdocsAuthUrl,
getKdocsTokenStatus,
getKdocsUserInfo,
getKdocsFileList,
getKdocsSheetList,
readKdocsCells,
updateKdocsCells
} from '@/api/jarvis/kdocs'
export default {
name: 'KdocsCloud',
data() {
return {
loading: false,
isAuthorized: false,
userInfo: null,
userId: 'default_user',
fileList: [],
fileListLoading: false,
listHint: '',
hasMoreFiles: false,
nextOffset: null,
nextFilter: null,
editDialogVisible: false,
currentFile: null,
currentSheetIdx: 0,
sheetList: [],
sheetData: [],
sheetDataLoading: false,
cellRange: 'A1:Z100',
sheetColumns: []
}
},
created() {
this.checkAuthStatus()
},
methods: {
async checkAuthStatus() {
try {
const response = await getKdocsTokenStatus(this.userId)
if (response.code === 200) {
this.isAuthorized = !!response.data.hasToken
if (this.isAuthorized) {
this.loadUserInfo()
}
}
} catch (e) {
console.error(e)
}
},
async loadUserInfo() {
try {
const response = await getKdocsUserInfo(this.userId)
if (response.code === 200) {
this.userInfo = response.data
}
} catch (e) {
console.error(e)
}
},
async handleAuthorize() {
try {
const response = await getKdocsAuthUrl()
if (response.code === 200) {
window.open(response.data, '_blank')
this.$message.success('请在新窗口完成授权,完成后回到本页刷新')
}
} catch (e) {
this.$message.error(e.msg || e.message || '获取授权地址失败')
}
},
handleRefresh() {
this.checkAuthStatus()
if (this.isAuthorized) {
this.handleLoadFiles(true)
}
},
/** @param {boolean} reset 是否重置游标从第一页拉取 */
async handleLoadFiles(reset) {
if (!this.isAuthorized) {
this.$message.warning('请先授权')
return
}
if (reset) {
this.nextOffset = null
this.nextFilter = null
this.fileList = []
}
this.fileListLoading = true
this.listHint = ''
try {
const params = {
userId: this.userId,
page: 1,
pageSize: 50
}
if (this.nextOffset != null) {
params.next_offset = this.nextOffset
}
if (this.nextFilter) {
params.next_filter = this.nextFilter
}
const response = await getKdocsFileList(params)
if (response.code === 200) {
const chunk = response.data.files || []
this.fileList = reset ? chunk : this.fileList.concat(chunk)
this.nextOffset = response.data.next_offset
this.nextFilter = response.data.next_filter || null
this.hasMoreFiles = !!response.data.has_more
this.listHint =
'file_token 请使用列表中的值(来自文档 open_id。在线表格工作表请选 sheet_type 为 et 的 sheet数据表为 db 时需用数据表 API。'
}
} catch (e) {
this.$message.error(e.msg || e.message || '加载失败')
} finally {
this.fileListLoading = false
}
},
handleLoadMore() {
if (!this.hasMoreFiles) return
this.handleLoadFiles(false)
},
async handleEditFile(file) {
this.currentFile = file
this.editDialogVisible = true
try {
const response = await getKdocsSheetList(this.userId, file.file_token)
if (response.code === 200) {
this.sheetList = response.data.sheets || []
if (this.sheetList.length > 0) {
const first = this.sheetList[0]
this.currentSheetIdx = first.sheet_idx != null ? first.sheet_idx : 0
this.handleLoadSheetData()
} else {
this.$message.warning('未获取到工作表列表')
}
}
} catch (e) {
this.$message.error(e.msg || e.message || '加载工作表失败')
}
},
extractCellValues(payload) {
if (!payload) return []
if (Array.isArray(payload.values)) return payload.values
if (payload.data && Array.isArray(payload.data.values)) return payload.data.values
return []
},
async handleLoadSheetData() {
if (!this.currentFile) return
this.sheetDataLoading = true
try {
const response = await readKdocsCells({
userId: this.userId,
fileToken: this.currentFile.file_token,
sheetIdx: this.currentSheetIdx,
range: this.cellRange
})
if (response.code === 200) {
this.processSheetData(this.extractCellValues(response.data))
}
} catch (e) {
this.$message.error(e.msg || e.message || '读取失败')
} finally {
this.sheetDataLoading = false
}
},
processSheetData(values) {
if (!values || values.length === 0) {
this.sheetData = []
this.sheetColumns = []
return
}
const maxCols = Math.max(...values.map(row => (row ? row.length : 0)))
this.sheetColumns = Array.from({ length: maxCols }, (_, i) => i)
this.sheetData = values.map(row => {
const rowData = {}
const r = row || []
r.forEach((cell, index) => {
rowData['col' + index] = cell !== null && cell !== undefined ? String(cell) : ''
})
for (let i = r.length; i < maxCols; i++) {
rowData['col' + i] = ''
}
return rowData
})
},
getColumnLabel(index) {
let label = ''
let num = index
while (num >= 0) {
label = String.fromCharCode(65 + (num % 26)) + label
num = Math.floor(num / 26) - 1
}
return label
},
async handleUpdateCells() {
if (!this.currentFile) return
const values = this.sheetData.map(row => {
return this.sheetColumns.map(colIndex => {
const v = row['col' + colIndex]
return v !== undefined ? v : ''
})
})
try {
const response = await updateKdocsCells({
userId: this.userId,
fileToken: this.currentFile.file_token,
sheetIdx: this.currentSheetIdx,
range: this.cellRange,
values
})
if (response.code === 200) {
this.$message.success('保存成功')
}
} catch (e) {
this.$message.error(e.msg || e.message || '保存失败')
}
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
.list-hint {
color: #909399;
font-size: 13px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,208 @@
<template>
<div class="app-container">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="腾锋" name="腾锋">
<div class="phone-config-container">
<el-card>
<div slot="header" class="clearfix">
<span>腾锋手机号配置</span>
<el-button style="float: right; padding: 3px 0" type="text" @click="handleAddPhone('腾锋')">添加手机号</el-button>
</div>
<el-table v-loading="loading" :data="tfPhoneList" border>
<el-table-column label="序号" type="index" width="80" align="center" />
<el-table-column label="手机号" align="center" prop="phone" />
<el-table-column label="操作" align="center" width="150">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleRemovePhone('腾锋', scope.row.phone)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px;">
<el-button type="primary" @click="handleSave('腾锋')">保存配置</el-button>
</div>
</el-card>
</div>
</el-tab-pane>
<el-tab-pane label="昭迎" name="昭迎">
<div class="phone-config-container">
<el-card>
<div slot="header" class="clearfix">
<span>昭迎手机号配置</span>
<el-button style="float: right; padding: 3px 0" type="text" @click="handleAddPhone('昭迎')">添加手机号</el-button>
</div>
<el-table v-loading="loading" :data="zyPhoneList" border>
<el-table-column label="序号" type="index" width="80" align="center" />
<el-table-column label="手机号" align="center" prop="phone" />
<el-table-column label="操作" align="center" width="150">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleRemovePhone('昭迎', scope.row.phone)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px;">
<el-button type="primary" @click="handleSave('昭迎')">保存配置</el-button>
</div>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
<!-- 添加手机号对话框 -->
<el-dialog :title="'添加手机号 - ' + currentType" :visible.sync="addPhoneDialogVisible" width="400px" append-to-body>
<el-form ref="addPhoneForm" :model="addPhoneForm" :rules="addPhoneRules" label-width="80px">
<el-form-item label="手机号" prop="phone">
<el-input v-model="addPhoneForm.phone" placeholder="请输入11位手机号" maxlength="11" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitAddPhone"> </el-button>
<el-button @click="addPhoneDialogVisible = false"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getPhoneList, setPhoneList, addPhone, removePhone } from "@/api/jarvis/phoneReplaceConfig";
export default {
name: "PhoneReplaceConfig",
data() {
// 手机号验证规则
const validatePhone = (rule, value, callback) => {
if (!value) {
callback(new Error('请输入手机号'));
} else if (!/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的11位手机号'));
} else {
callback();
}
};
return {
// 遮罩层
loading: false,
// 当前激活的标签页
activeTab: "腾锋",
// 腾锋手机号列表
tfPhoneList: [],
// 昭迎手机号列表
zyPhoneList: [],
// 添加手机号对话框
addPhoneDialogVisible: false,
// 当前操作的类型
currentType: "腾锋",
// 添加手机号表单
addPhoneForm: {
phone: ""
},
// 添加手机号表单校验
addPhoneRules: {
phone: [
{ required: true, validator: validatePhone, trigger: 'blur' }
]
}
};
},
created() {
this.loadPhoneList("腾锋");
this.loadPhoneList("昭迎");
},
methods: {
/** 加载手机号列表 */
loadPhoneList(type) {
this.loading = true;
getPhoneList(type).then(response => {
const phoneList = response.data || [];
const formattedList = phoneList.map(phone => ({ phone }));
if (type === "腾锋") {
this.tfPhoneList = formattedList;
} else if (type === "昭迎") {
this.zyPhoneList = formattedList;
}
this.loading = false;
}).catch(() => {
this.loading = false;
});
},
/** 标签页切换 */
handleTabClick(tab) {
// 标签页切换时不需要重新加载因为已经在created中加载了
},
/** 添加手机号 */
handleAddPhone(type) {
this.currentType = type;
this.addPhoneForm.phone = "";
this.addPhoneDialogVisible = true;
this.$nextTick(() => {
if (this.$refs.addPhoneForm) {
this.$refs.addPhoneForm.clearValidate();
}
});
},
/** 提交添加手机号 */
submitAddPhone() {
this.$refs.addPhoneForm.validate(valid => {
if (valid) {
const phoneList = this.currentType === "腾锋" ? this.tfPhoneList : this.zyPhoneList;
// 检查是否已存在
if (phoneList.some(item => item.phone === this.addPhoneForm.phone)) {
this.$modal.msgError("该手机号已存在");
return;
}
// 添加到列表
phoneList.push({ phone: this.addPhoneForm.phone });
// 保存到后端
const phoneArray = phoneList.map(item => item.phone);
setPhoneList(this.currentType, phoneArray).then(() => {
this.$modal.msgSuccess("添加成功");
this.addPhoneDialogVisible = false;
this.loadPhoneList(this.currentType);
});
}
});
},
/** 删除手机号 */
handleRemovePhone(type, phone) {
this.$modal.confirm('是否确认删除手机号"' + phone + '"').then(() => {
const phoneList = type === "腾锋" ? this.tfPhoneList : this.zyPhoneList;
const index = phoneList.findIndex(item => item.phone === phone);
if (index > -1) {
phoneList.splice(index, 1);
const phoneArray = phoneList.map(item => item.phone);
return setPhoneList(type, phoneArray);
}
}).then(() => {
this.$modal.msgSuccess("删除成功");
this.loadPhoneList(type);
}).catch(() => {});
},
/** 保存配置 */
handleSave(type) {
const phoneList = type === "腾锋" ? this.tfPhoneList : this.zyPhoneList;
const phoneArray = phoneList.map(item => item.phone);
setPhoneList(type, phoneArray).then(() => {
this.$modal.msgSuccess("保存成功");
this.loadPhoneList(type);
});
}
}
};
</script>
<style scoped>
.phone-config-container {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch">
<el-form-item label="产品型号" prop="productModel">
<el-input
v-model="queryParams.productModel"
placeholder="请输入产品型号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['jarvis:productJdConfig:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['jarvis:productJdConfig:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['jarvis:productJdConfig:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleInitData"
v-hasPermi="['jarvis:productJdConfig:init']"
>初始化数据</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="productJdConfigList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="产品型号" align="center" prop="productModel" :show-overflow-tooltip="true" />
<el-table-column label="京东链接" align="center" prop="jdUrl" :show-overflow-tooltip="true">
<template slot-scope="scope">
<el-link :href="scope.row.jdUrl" target="_blank" type="primary">{{ scope.row.jdUrl }}</el-link>
</template>
</el-table-column>
<el-table-column label="佣金(收取)" align="center" prop="commissionReceive" width="120">
<template slot-scope="scope">
<span>{{ scope.row.commissionReceive ? '¥' + scope.row.commissionReceive : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="佣金(支付)" align="center" prop="commissionPay" width="120">
<template slot-scope="scope">
<span>{{ scope.row.commissionPay ? '¥' + scope.row.commissionPay : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="参考售价(直款)" align="center" prop="sellingPriceDirect" width="120">
<template slot-scope="scope">
<span>{{ scope.row.sellingPriceDirect != null ? '¥' + scope.row.sellingPriceDirect : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="参考售价(闲鱼)" align="center" prop="sellingPriceXianyu" width="120">
<template slot-scope="scope">
<span>{{ scope.row.sellingPriceXianyu != null ? '¥' + scope.row.sellingPriceXianyu : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['jarvis:productJdConfig:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['jarvis:productJdConfig:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加或修改产品京东配置对话框 -->
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="产品型号" prop="productModel">
<el-input v-model="form.productModel" placeholder="请输入产品型号" :disabled="form.productModel && !isAdd" />
</el-form-item>
<el-form-item label="京东链接" prop="jdUrl">
<el-input v-model="form.jdUrl" type="textarea" :rows="3" placeholder="请输入京东链接" />
</el-form-item>
<el-form-item label="佣金(收取)" prop="commissionReceive">
<el-input-number v-model="form.commissionReceive" :precision="2" :step="0.1" :min="0" placeholder="请输入佣金(收取)" style="width: 100%;" />
</el-form-item>
<el-form-item label="佣金(支付)" prop="commissionPay">
<el-input-number v-model="form.commissionPay" :precision="2" :step="0.1" :min="0" placeholder="请输入佣金(支付)" style="width: 100%;" />
</el-form-item>
<el-form-item label="参考售价(直款)" prop="sellingPriceDirect">
<el-input-number v-model="form.sellingPriceDirect" :precision="2" :step="1" :min="0" placeholder="F 单直款渠道默认售价" style="width: 100%;" />
</el-form-item>
<el-form-item label="参考售价(闲鱼)" prop="sellingPriceXianyu">
<el-input-number v-model="form.sellingPriceXianyu" :precision="2" :step="1" :min="0" placeholder="F 单闲鱼标价系统会×0.984" style="width: 100%;" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listProductJdConfig, getProductJdConfig, delProductJdConfig, addProductJdConfig, updateProductJdConfig, initDefaultData } from "@/api/jarvis/productJdConfig";
export default {
name: "ProductJdConfig",
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 产品京东配置表格数据
productJdConfigList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 是否为新增
isAdd: false,
// 查询参数
queryParams: {
productModel: null,
},
// 表单参数
form: {},
// 表单校验
rules: {
productModel: [
{ required: true, message: "产品型号不能为空", trigger: "blur" }
],
jdUrl: [
{ required: true, message: "京东链接不能为空", trigger: "blur" }
],
commissionReceive: [
{ required: true, message: "佣金(收取)不能为空", trigger: "blur" }
],
commissionPay: [
{ required: true, message: "佣金(支付)不能为空", trigger: "blur" }
]
}
};
},
created() {
this.getList();
},
methods: {
/** 查询产品京东配置列表 */
getList() {
this.loading = true;
listProductJdConfig(this.queryParams).then(response => {
this.productJdConfigList = response.data || [];
this.loading = false;
});
},
// 取消按钮
cancel() {
this.open = false;
this.reset();
},
// 表单重置
reset() {
this.form = {
productModel: null,
jdUrl: null,
commission: 0,
commissionReceive: 0,
commissionPay: 0,
sellingPriceDirect: undefined,
sellingPriceXianyu: undefined
};
this.resetForm("form");
},
/** 搜索按钮操作 */
handleQuery() {
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.productModel);
this.single = selection.length !== 1;
this.multiple = !selection.length;
},
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.isAdd = true;
this.open = true;
this.title = "添加产品京东配置";
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
this.isAdd = false;
const productModel = row.productModel || this.ids[0];
getProductJdConfig(productModel).then(response => {
this.form = response.data;
this.open = true;
this.title = "修改产品京东配置";
});
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.isAdd) {
addProductJdConfig(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
} else {
updateProductJdConfig(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
});
}
}
});
},
/** 删除按钮操作 */
handleDelete(row) {
const productModels = row.productModel ? [row.productModel] : this.ids;
this.$modal.confirm('是否确认删除产品型号为"' + productModels + '"的数据项?').then(() => {
return delProductJdConfig(productModels.join(','));
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
},
/** 初始化数据按钮操作 */
handleInitData() {
this.$modal.confirm('是否确认初始化默认产品数据?').then(() => {
return initDefaultData();
}).then(() => {
this.getList();
this.$modal.msgSuccess("初始化成功");
}).catch(() => {});
}
}
};
</script>

View File

@@ -0,0 +1,260 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="88px">
<el-form-item label="发送人" prop="fromUserName">
<el-input v-model="queryParams.fromUserName" placeholder="UserID" clearable style="width: 160px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="MsgId" prop="msgId">
<el-input v-model="queryParams.msgId" placeholder="精确匹配" clearable style="width: 200px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="AgentID" prop="agentId">
<el-input v-model="queryParams.agentId" placeholder="应用ID" clearable style="width: 120px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="会话中" prop="sessionActive">
<el-select v-model="queryParams.sessionActive" placeholder="全部" clearable style="width: 100px">
<el-option label="是" :value="1" />
<el-option label="否" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="接收时间">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="yyyy-MM-dd"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['jarvis:wecom:inboundTrace:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-delete-solid"
size="mini"
@click="openCleanDialog"
v-hasPermi="['jarvis:wecom:inboundTrace:remove']"
>清空测试数据</el-button>
</el-col>
</el-row>
<el-table v-loading="loading" :data="list" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="ID" prop="id" width="72" />
<el-table-column label="AgentID" prop="agentId" width="96" />
<el-table-column label="发送人" prop="fromUserName" width="120" show-overflow-tooltip />
<el-table-column label="内容摘要" prop="content" min-width="180" show-overflow-tooltip />
<el-table-column label="微信时间" prop="wxMsgTime" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.wxMsgTime) || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="服务端接收" prop="createTime" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="回复摘要" prop="replyContent" min-width="160" show-overflow-tooltip />
<el-table-column label="会话空间" width="88" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.sessionActive === 1" type="warning" size="small">进行中</el-tag>
<el-tag v-else type="info" size="small"></el-tag>
</template>
</el-table-column>
<el-table-column label="场景/步骤" width="140" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.sessionScene">{{ scope.row.sessionScene }} / {{ scope.row.sessionStep || '' }}</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-view" @click="openDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog title="清空测试数据" :visible.sync="cleanOpen" width="520px" append-to-body @close="resetCleanForm">
<p style="color:#E6A23C;margin:0 0 12px 0;">将按下方勾选删除数据生产环境请谨慎操作</p>
<el-checkbox v-model="cleanOptions.clearTraceTable">消息追踪表wecom_inbound_trace 全部行</el-checkbox>
<br><br>
<el-checkbox v-model="cleanOptions.clearWecomSessions">企微多轮会话 Redisinteraction_state:wecom:*</el-checkbox>
<br><br>
<el-checkbox v-model="cleanOptions.clearAdhocQueue">分享链待扫描队列logistics:adhoc:pending:queue</el-checkbox>
<div slot="footer" class="dialog-footer">
<el-button @click="cleanOpen = false"> </el-button>
<el-button type="danger" @click="submitClean"> 定清理</el-button>
</div>
</el-dialog>
<el-dialog title="消息详情" :visible.sync="detailOpen" width="720px" append-to-body>
<el-descriptions :column="1" border size="small" v-if="detail">
<el-descriptions-item label="MsgId">{{ detail.msgId || '—' }}</el-descriptions-item>
<el-descriptions-item label="AgentID">{{ detail.agentId || '—' }}</el-descriptions-item>
<el-descriptions-item label="CorpId">{{ detail.corpId || '—' }}</el-descriptions-item>
<el-descriptions-item label="发送人">{{ detail.fromUserName }}</el-descriptions-item>
<el-descriptions-item label="微信发送时间">{{ parseTime(detail.wxMsgTime) || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务端接收">{{ parseTime(detail.createTime) }}</el-descriptions-item>
<el-descriptions-item label="用户内容">
<pre class="trace-pre">{{ detail.content || '(空)' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="Jarvis 回复">
<pre class="trace-pre">{{ detail.replyContent || '(空)' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="会话">
<span v-if="detail.sessionActive === 1">进行中 {{ detail.sessionScene }} / {{ detail.sessionStep }}</span>
<span v-else>本次处理后无未完成的多轮会话</span>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script>
import { listWecomInboundTrace, delWecomInboundTrace, getWecomInboundTrace, cleanWecomInboundTraceTestData } from '@/api/jarvis/wecomInboundTrace'
export default {
name: 'WecomInboundTrace',
data() {
return {
loading: true,
ids: [],
single: true,
multiple: true,
total: 0,
list: [],
dateRange: [],
detailOpen: false,
detail: null,
cleanOpen: false,
cleanOptions: {
clearTraceTable: true,
clearWecomSessions: true,
clearAdhocQueue: true
},
queryParams: {
pageNum: 1,
pageSize: 10,
fromUserName: null,
msgId: null,
agentId: null,
sessionActive: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
const q = { ...this.queryParams, params: {} }
if (this.dateRange && this.dateRange.length === 2) {
q.params = { beginTime: this.dateRange[0], endTime: this.dateRange[1] }
}
listWecomInboundTrace(q).then(res => {
this.list = res.rows
this.total = res.total
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id)
this.single = selection.length !== 1
this.multiple = !selection.length
},
handleDelete() {
const ids = this.ids
this.$modal.confirm('是否确认删除选中的追踪记录?').then(() => {
return delWecomInboundTrace(ids)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
openDetail(row) {
getWecomInboundTrace(row.id).then(res => {
this.detail = res.data
this.detailOpen = true
})
},
openCleanDialog() {
this.resetCleanForm()
this.cleanOpen = true
},
resetCleanForm() {
this.cleanOptions = {
clearTraceTable: true,
clearWecomSessions: true,
clearAdhocQueue: true
}
},
submitClean() {
if (!this.cleanOptions.clearTraceTable && !this.cleanOptions.clearWecomSessions && !this.cleanOptions.clearAdhocQueue) {
this.$modal.msgWarning('请至少勾选一项')
return
}
this.$modal.confirm('确认按勾选项清理测试数据?此操作不可恢复。').then(() => {
return cleanWecomInboundTraceTestData(this.cleanOptions)
}).then(res => {
this.cleanOpen = false
this.getList()
const d = res.data || {}
const parts = []
if (d.traceRowsDeleted != null) parts.push('追踪表删除 ' + d.traceRowsDeleted + ' 行')
if (d.wecomSessionKeysDeleted != null) parts.push('会话键 ' + d.wecomSessionKeysDeleted + ' 个')
if (d.adhocQueueCleared) parts.push('adhoc 队列已删')
this.$modal.msgSuccess(parts.length ? parts.join('') : '已执行')
}).catch(() => {})
}
}
}
</script>
<style scoped>
.trace-pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: inherit;
font-size: 13px;
max-height: 280px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,366 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="88px">
<el-form-item label="发送人" prop="fromUserName">
<el-input v-model="queryParams.fromUserName" placeholder="企微 UserID" clearable style="width: 140px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
<el-option label="待扫描" value="PENDING" />
<el-option label="等待运单/重试" value="WAITING" />
<el-option label="已推送" value="PUSHED" />
<el-option label="已放弃" value="ABANDONED" />
<el-option label="历史补录" value="IMPORTED" />
<el-option label="已取消" value="CANCELLED" />
</el-select>
</el-form-item>
<el-form-item label="短链" prop="trackingUrl">
<el-input v-model="queryParams.trackingUrl" placeholder="3.cn 片段" clearable style="width: 180px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="运单号" prop="waybillNo">
<el-input v-model="queryParams.waybillNo" placeholder="模糊" clearable style="width: 120px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="备注" prop="userRemark">
<el-input v-model="queryParams.userRemark" placeholder="模糊" clearable style="width: 160px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="入队时间">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="yyyy-MM-dd"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-alert
title="用于排查企微 3.cn 分享链物流WAITING + no_waybill_yet 未出单push_failed 推送未成功ABANDONED 为当轮队列重试用尽定时对账一月内会归零次数并重新入队IMPORTED 为从「企微消息跟踪」补录留痕CANCELLED 为已取消扫描(订单取消等),不再对账入队,队列弹出也会跳过。"
type="info"
:closable="false"
show-icon
class="mb8"
/>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-video-play"
size="mini"
:loading="drainQueueLoading"
v-hasPermi="['jarvis:wecom:shareLinkLog:list']"
@click="handleDrainQueueOnce"
>执行待队列一轮</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-upload2"
size="mini"
:loading="backfillLoading"
v-hasPermi="['jarvis:wecom:shareLinkLog:import']"
@click="handleBackfill"
>从追踪补录历史</el-button>
</el-col>
</el-row>
<el-table v-loading="loading" :data="list" border>
<el-table-column label="ID" prop="id" width="72" />
<el-table-column label="发送人" prop="fromUserName" width="120" show-overflow-tooltip />
<el-table-column label="推送目标" prop="touserPush" width="140" show-overflow-tooltip />
<el-table-column label="状态" prop="status" width="100" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 'PUSHED'" type="success" size="small">{{ scope.row.status }}</el-tag>
<el-tag v-else-if="scope.row.status === 'WAITING'" type="warning" size="small">{{ scope.row.status }}</el-tag>
<el-tag v-else-if="scope.row.status === 'ABANDONED'" type="danger" size="small">{{ scope.row.status }}</el-tag>
<el-tag v-else-if="scope.row.status === 'IMPORTED'" type="info" size="small">{{ scope.row.status }}</el-tag>
<el-tag v-else-if="scope.row.status === 'CANCELLED'" type="info" size="small">{{ scope.row.status }}</el-tag>
<el-tag v-else type="info" size="small">{{ scope.row.status || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="扫描次数" prop="scanAttempts" width="88" align="center" />
<el-table-column label="运单号" prop="waybillNo" width="140" show-overflow-tooltip />
<el-table-column label="用户备注" prop="userRemark" min-width="160" show-overflow-tooltip />
<el-table-column label="最近说明" prop="lastNote" min-width="140" show-overflow-tooltip />
<el-table-column label="短链" prop="trackingUrl" min-width="160" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createTime" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="248" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-view" @click="openDetail(scope.row)">详情</el-button>
<el-button type="text" size="mini" icon="el-icon-truck" @click="handleFetchShareLink(scope.row)">获取物流</el-button>
<el-button
v-if="scope.row.status !== 'PUSHED' && scope.row.status !== 'CANCELLED'"
type="text"
size="mini"
icon="el-icon-circle-close"
@click="handleCancelJob(scope.row)"
>取消扫描</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" @click="handleRemoveJob(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog title="获取物流信息(分享链)" :visible.sync="fetchLogisticsDialogVisible" width="720px" append-to-body @close="fetchLogisticsResult = null">
<div v-loading="fetchLogisticsLoading">
<el-alert
v-if="fetchLogisticsResult"
:title="fetchLogisticsResult.success ? '请求已完成' : '请求失败'"
:type="fetchLogisticsResult.success ? 'success' : 'error'"
:closable="false"
show-icon
class="mb8"
/>
<el-form v-if="fetchLogisticsResult && fetchLogisticsResult.success" label-width="120px" size="small">
<el-form-item label="jobKey"><span>{{ fetchLogisticsResult.jobKey }}</span></el-form-item>
<el-form-item label="终端成功"><span>{{ fetchLogisticsResult.terminalSuccess }}</span>已推送或已去重</el-form-item>
<el-form-item label="已发推送"><span>{{ fetchLogisticsResult.pushSent }}</span></el-form-item>
<el-form-item label="运单号" v-if="fetchLogisticsResult.waybillNo"><span>{{ fetchLogisticsResult.waybillNo }}</span></el-form-item>
<el-form-item label="说明" v-if="fetchLogisticsResult.adhocNote"><span>{{ fetchLogisticsResult.adhocNote }}</span></el-form-item>
<el-form-item label="物流短链"><span style="word-break:break-all">{{ fetchLogisticsResult.logisticsLink }}</span></el-form-item>
<el-form-item label="请求URL" v-if="fetchLogisticsResult.requestUrl">
<el-input type="textarea" :rows="2" readonly :value="fetchLogisticsResult.requestUrl" />
</el-form-item>
<el-form-item label="健康检查" v-if="fetchLogisticsResult.healthOk != null">
<span>{{ fetchLogisticsResult.healthOk }}</span>
<span v-if="fetchLogisticsResult.healthMessage"> {{ fetchLogisticsResult.healthMessage }}</span>
</el-form-item>
<el-form-item label="推送错误" v-if="fetchLogisticsResult.pushError">
<el-input type="textarea" :rows="3" readonly :value="fetchLogisticsResult.pushError" />
</el-form-item>
<el-form-item label="返回(原始)" v-if="fetchLogisticsResult.responseRaw">
<el-input type="textarea" :rows="5" readonly :value="fetchLogisticsResult.responseRaw" />
</el-form-item>
<el-form-item label="返回(解析)" v-if="fetchLogisticsResult.responseData">
<el-input type="textarea" :rows="8" readonly :value="formatJson(fetchLogisticsResult.responseData)" />
</el-form-item>
</el-form>
<el-form v-else-if="fetchLogisticsResult && !fetchLogisticsResult.success" label-width="100px" size="small">
<el-form-item label="错误"><span>{{ fetchLogisticsResult.error }}</span></el-form-item>
</el-form>
<span v-else-if="fetchLogisticsLoading" style="color:#999">正在请求</span>
</div>
<div slot="footer">
<el-button @click="fetchLogisticsDialogVisible = false">关闭</el-button>
<el-button type="primary" v-if="fetchLogisticsResult && fetchLogisticsResult.success" @click="copyFetchLogisticsResult">复制 JSON</el-button>
</div>
</el-dialog>
<el-dialog title="任务详情" :visible.sync="detailOpen" width="720px" append-to-body>
<el-descriptions v-if="detail" :column="1" border size="small">
<el-descriptions-item label="jobKey">{{ detail.jobKey }}</el-descriptions-item>
<el-descriptions-item label="发送人">{{ detail.fromUserName || '—' }}</el-descriptions-item>
<el-descriptions-item label="推送接收人">{{ detail.touserPush || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item>
<el-descriptions-item label="扫描次数">{{ detail.scanAttempts }}</el-descriptions-item>
<el-descriptions-item label="运单号">{{ detail.waybillNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="最近说明">{{ detail.lastNote || '—' }}</el-descriptions-item>
<el-descriptions-item label="短链">
<span style="word-break:break-all">{{ detail.trackingUrl }}</span>
</el-descriptions-item>
<el-descriptions-item label="用户备注">
<pre class="trace-pre">{{ detail.userRemark || '(空)' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ parseTime(detail.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ parseTime(detail.updateTime) }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script>
import { listWecomShareLinkLogisticsJob, getWecomShareLinkLogisticsJob, backfillShareLinkLogisticsFromTrace, fetchShareLinkManually, drainShareLinkPendingQueueOnce, cancelWecomShareLinkJob, removeWecomShareLinkJob } from '@/api/jarvis/wecomShareLinkLogistics'
export default {
name: 'WecomShareLinkLogistics',
data() {
return {
backfillLoading: false,
drainQueueLoading: false,
fetchLogisticsDialogVisible: false,
fetchLogisticsLoading: false,
fetchLogisticsResult: null,
loading: true,
total: 0,
list: [],
dateRange: [],
detailOpen: false,
detail: null,
queryParams: {
pageNum: 1,
pageSize: 10,
fromUserName: null,
status: null,
trackingUrl: null,
waybillNo: null,
userRemark: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
const q = { ...this.queryParams, params: {} }
if (this.dateRange && this.dateRange.length === 2) {
q.params = { beginTime: this.dateRange[0], endTime: this.dateRange[1] }
}
listWecomShareLinkLogisticsJob(q).then(res => {
this.list = res.rows
this.total = res.total
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
openDetail(row) {
getWecomShareLinkLogisticsJob(row.jobKey).then(res => {
this.detail = res.data
this.detailOpen = true
})
},
formatJson(v) {
if (v == null) return ''
if (typeof v === 'string') return v
try {
return JSON.stringify(v, null, 2)
} catch (e) {
return String(v)
}
},
async handleFetchShareLink(row) {
if (!row.trackingUrl || !String(row.trackingUrl).trim()) {
this.$message.warning('该任务暂无物流短链')
return
}
this.fetchLogisticsDialogVisible = true
this.fetchLogisticsLoading = true
this.fetchLogisticsResult = null
try {
const res = await fetchShareLinkManually({ jobKey: row.jobKey })
if (res.code === 200) {
this.fetchLogisticsResult = { success: true, ...res.data }
this.$message.success('已请求物流接口,列表状态已更新')
this.getList()
} else {
this.fetchLogisticsResult = { success: false, error: res.msg || '失败' }
this.$message.error(res.msg || '获取失败')
}
} catch (e) {
this.fetchLogisticsResult = { success: false, error: e.message || '请求异常' }
this.$message.error('获取物流失败: ' + (e.message || '未知错误'))
} finally {
this.fetchLogisticsLoading = false
}
},
copyFetchLogisticsResult() {
if (!this.fetchLogisticsResult) return
const t = JSON.stringify(this.fetchLogisticsResult, null, 2)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(t).then(() => this.$message.success('已复制到剪贴板')).catch(() => this.fallbackCopy(t))
} else {
this.fallbackCopy(t)
}
},
fallbackCopy(text) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
this.$message.success('已复制到剪贴板')
} catch (e) {
this.$message.error('复制失败')
}
document.body.removeChild(textArea)
},
handleDrainQueueOnce() {
this.$modal.confirm('立即执行一轮 Redis 待扫描队列(与定时任务末尾逻辑相同,每批条数受后端 adhoc-pending-batch-size 限制)?').then(() => {
this.drainQueueLoading = true
return drainShareLinkPendingQueueOnce()
}).then(res => {
const d = res.data || {}
this.$modal.msgSuccess('已弹出处理 ' + (d.processedFromQueue != null ? d.processedFromQueue : 0) + ' 条')
this.getList()
}).catch(() => {}).finally(() => { this.drainQueueLoading = false })
},
handleBackfill() {
this.$modal.confirm('从「企微消息跟踪」补录历史分享链任务(状态 IMPORTED已存在的 jobKey 将跳过。是否继续?').then(() => {
this.backfillLoading = true
return backfillShareLinkLogisticsFromTrace()
}).then(res => {
const d = res.data || {}
const parts = [
'扫描 ' + (d.scannedRemarkDoneRows != null ? d.scannedRemarkDoneRows : '—') + ' 条',
'新增 ' + (d.imported != null ? d.imported : 0),
'跳过已有 ' + (d.skippedDuplicate != null ? d.skippedDuplicate : 0),
'无短链 ' + (d.skippedNoUrl != null ? d.skippedNoUrl : 0)
]
this.$modal.msgSuccess(parts.join(''))
this.getList()
}).catch(() => {}).finally(() => { this.backfillLoading = false })
},
handleCancelJob(row) {
const tip = '取消后该 3.cn 任务不再自动扫描/对账入队;若 Redis 队列里还有同一 jobKey弹出时也会跳过。订单取消可选此操作。是否继续'
this.$modal.confirm(tip).then(() => cancelWecomShareLinkJob({ jobKey: row.jobKey, lastNote: 'order_cancel' })).then(() => {
this.$modal.msgSuccess('已取消扫描')
this.getList()
}).catch(() => {})
},
handleRemoveJob(row) {
this.$modal.confirm('将永久删除该任务行jobKey=' + row.jobKey + ')。是否继续?').then(() => removeWecomShareLinkJob(row.jobKey)).then(() => {
this.$modal.msgSuccess('已删除')
this.getList()
}).catch(() => {})
}
}
}
</script>
<style scoped>
.trace-pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: inherit;
font-size: 13px;
max-height: 280px;
overflow: auto;
}
.mb8 {
margin-bottom: 8px;
}
</style>

View File

@@ -24,17 +24,19 @@
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item prop="code" v-if="captchaEnabled"> <el-form-item prop="code" v-if="captchaEnabled">
<el-input <div style="display: flex; gap: 10px; align-items: center;">
v-model="loginForm.code" <el-input
auto-complete="off" v-model="loginForm.code"
placeholder="验证码" auto-complete="off"
style="width: 63%" placeholder="验证码"
@keyup.enter.native="handleLogin" style="flex: 1"
> @keyup.enter.native="handleLogin"
<svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon" /> >
</el-input> <svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon" />
<div class="login-code"> </el-input>
<img :src="codeUrl" @click="getCode" class="login-code-img"/> <div class="login-code">
<img :src="codeUrl" @click="getCode" class="login-code-img"/>
</div>
</div> </div>
</el-form-item> </el-form-item>
<el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox> <el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
@@ -73,8 +75,8 @@ export default {
title: process.env.VUE_APP_TITLE, title: process.env.VUE_APP_TITLE,
codeUrl: "", codeUrl: "",
loginForm: { loginForm: {
username: "admin", username: "",
password: "admin123", password: "",
rememberMe: false, rememberMe: false,
code: "", code: "",
uuid: "" uuid: ""
@@ -142,7 +144,17 @@ export default {
Cookies.remove('rememberMe') Cookies.remove('rememberMe')
} }
this.$store.dispatch("Login", this.loginForm).then(() => { this.$store.dispatch("Login", this.loginForm).then(() => {
this.$router.push({ path: this.redirect || "/order/list" }).catch(()=>{}) // 先获取用户信息和生成路由,然后再跳转
this.$store.dispatch('GetInfo').then(() => {
this.$store.dispatch('GenerateRoutes').then(() => {
// 使用 replace 而不是 push避免路由历史问题
const redirectPath = this.redirect || "/sloworder/index"
this.$router.replace(redirectPath).catch(() => {
// 如果目标路由不存在,跳转到默认路由
this.$router.replace("/sloworder/index")
})
})
})
}).catch(() => { }).catch(() => {
this.loading = false this.loading = false
if (this.captchaEnabled) { if (this.captchaEnabled) {
@@ -164,23 +176,55 @@ export default {
height: 100%; height: 100%;
background-image: url("../assets/images/login-background.jpg"); background-image: url("../assets/images/login-background.jpg");
background-size: cover; background-size: cover;
background-position: right center; /* 始终优先显示背景图右侧(含移动端) */
padding: 20px;
// 移动端优化
@media (max-width: 768px) {
padding: 10px;
align-items: flex-start;
padding-top: 10vh;
}
} }
.title { .title {
margin: 0px auto 30px auto; margin: 0px auto 30px auto;
text-align: center; text-align: center;
color: #707070; color: #707070;
font-size: 24px;
@media (max-width: 768px) {
font-size: 20px;
margin: 0px auto 20px auto;
}
} }
.login-form { .login-form {
border-radius: 6px; border-radius: 8px;
background: #ffffff; background: rgba(255, 255, 255, 0.75);
width: 400px; backdrop-filter: blur(10px);
padding: 25px 25px 5px 25px; width: 440px;
padding: 32px 32px 12px 32px;
z-index: 1; z-index: 1;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
// 移动端优化
@media (max-width: 768px) {
width: 100%;
max-width: 100%;
padding: 24px 20px 12px 20px;
border-radius: 10px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
.el-input { .el-input {
height: 38px; height: 38px;
input { input {
height: 38px; height: 38px;
font-size: 14px;
@media (max-width: 768px) {
font-size: 16px; // 防止iOS自动缩放
}
} }
} }
.input-icon { .input-icon {
@@ -188,6 +232,27 @@ export default {
width: 14px; width: 14px;
margin-left: 2px; margin-left: 2px;
} }
.el-form-item {
margin-bottom: 20px;
@media (max-width: 768px) {
margin-bottom: 18px;
}
}
.el-checkbox {
@media (max-width: 768px) {
font-size: 14px;
}
}
.el-button {
@media (max-width: 768px) {
height: 44px; // 增大触摸目标
font-size: 16px;
}
}
} }
.login-tip { .login-tip {
font-size: 13px; font-size: 13px;
@@ -198,9 +263,22 @@ export default {
width: 33%; width: 33%;
height: 38px; height: 38px;
float: right; float: right;
@media (max-width: 768px) {
width: 35%;
height: 44px;
}
img { img {
cursor: pointer; cursor: pointer;
vertical-align: middle; vertical-align: middle;
width: 100%;
height: 100%;
object-fit: contain;
@media (max-width: 768px) {
height: 44px;
}
} }
} }
.el-login-footer { .el-login-footer {
@@ -214,8 +292,18 @@ export default {
font-family: Arial; font-family: Arial;
font-size: 12px; font-size: 12px;
letter-spacing: 1px; letter-spacing: 1px;
@media (max-width: 768px) {
font-size: 11px;
height: 36px;
line-height: 36px;
}
} }
.login-code-img { .login-code-img {
height: 38px; height: 38px;
@media (max-width: 768px) {
height: 44px;
}
} }
</style> </style>

View File

@@ -0,0 +1,12 @@
<template>
<fadan-quick-record :is-mobile="true" />
</template>
<script>
import FadanQuickRecord from '@/views/system/jd-instruction/fadan-quick-record.vue'
export default {
name: 'MobileFadan',
components: { FadanQuickRecord }
}
</script>

View File

@@ -0,0 +1,267 @@
<template>
<div class="app-container mobile-zhongcao">
<el-card class="box-card">
<el-form :model="form" label-position="top" class="main-form">
<el-form-item label="商品标题" required>
<el-input
v-model="form.title"
type="textarea"
:rows="3"
placeholder="必填"
clearable
/>
</el-form-item>
<el-form-item label="型号" required>
<el-input v-model="form.model" placeholder="必填" clearable />
</el-form-item>
<el-form-item label="商品类型" required>
<el-input
v-model="form.goodsType"
placeholder="必填,如:冰箱、空调、洗衣机"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="btn-main" :loading="loading" @click="handleGenerate">
生成种草文案
</el-button>
<el-button :disabled="!hasInput" @click="clearAll">清空</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="seedNoteError"
:title="seedNoteError"
type="warning"
:closable="false"
show-icon
class="warn-alert"
/>
<div v-if="result.seedNote || result.daixiadan" class="result-wrap">
<template v-if="result.seedNote">
<el-divider content-position="left">种草文案AI</el-divider>
<div class="card-bar">
<span class="card-t">高转化种草</span>
<el-button type="primary" size="small" @click="copyText(result.seedNote)">复制</el-button>
</div>
<div class="result-body">{{ result.seedNote }}</div>
</template>
<el-collapse v-if="result.daixiadan" class="extra-collapse">
<el-collapse-item title="同步生成的闲鱼代下单 / 教你下单(模板拼接)" name="1">
<div v-if="result.daixiadan" class="sub-block">
<div class="card-bar">
<span class="card-t">代下单</span>
<el-button type="text" size="small" @click="copyText(result.daixiadan)">复制</el-button>
</div>
<pre class="pre-text">{{ result.daixiadan }}</pre>
</div>
<div v-if="result.jiaonixiadan" class="sub-block">
<div class="card-bar">
<span class="card-t">教你下单</span>
<el-button type="text" size="small" @click="copyText(result.jiaonixiadan)">复制</el-button>
</div>
<pre class="pre-text">{{ result.jiaonixiadan }}</pre>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</div>
</template>
<script>
import { generateXianyuWenan } from '@/api/jarvis/socialMedia'
export default {
name: 'MobileZhongcao',
data() {
return {
form: {
title: '',
model: '',
goodsType: ''
},
loading: false,
seedNoteError: '',
result: {
seedNote: '',
daixiadan: '',
jiaonixiadan: ''
}
}
},
computed: {
hasInput() {
return !!(this.form.title || this.form.model || this.form.goodsType)
}
},
methods: {
clearAll() {
this.form = { title: '', model: '', goodsType: '' }
this.seedNoteError = ''
this.result = { seedNote: '', daixiadan: '', jiaonixiadan: '' }
},
async handleGenerate() {
const title = (this.form.title || '').trim()
const model = (this.form.model || '').trim()
const goodsType = (this.form.goodsType || '').trim()
if (!title) {
this.$message.warning('请填写商品标题')
return
}
if (!model) {
this.$message.warning('请填写型号')
return
}
if (!goodsType) {
this.$message.warning('请填写商品类型')
return
}
this.loading = true
this.seedNoteError = ''
this.result = { seedNote: '', daixiadan: '', jiaonixiadan: '' }
try {
const res = await generateXianyuWenan({
title,
remark: model,
goods_title: title,
goods_model: model,
goods_type: goodsType,
generateSeedNote: true
})
if (res.code === 200 && res.data) {
const data = res.data
if (data.success) {
this.result.daixiadan = data.daixiadan || ''
this.result.jiaonixiadan = data.jiaonixiadan || ''
this.result.seedNote = (data.seedNote || '').trim()
if (data.seedNoteError) {
this.seedNoteError = data.seedNoteError
}
if (this.result.seedNote) {
this.$message.success('种草文案已生成')
} else if (this.seedNoteError) {
this.$message.warning('种草失败,已保留闲鱼基础文案')
} else {
this.$message.success('已生成')
}
} else {
this.$message.error(data.error || '生成失败')
}
} else {
this.$message.error(res.msg || '请求失败')
}
} catch (e) {
console.error(e)
this.$message.error('请求异常:' + (e.message || ''))
} finally {
this.loading = false
}
},
copyText(text) {
if (!text) {
this.$message.warning('无可复制内容')
return
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
this.$message.success('已复制')
}).catch(() => this.fallbackCopy(text))
} else {
this.fallbackCopy(text)
}
},
fallbackCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
this.$message.success('已复制')
} catch (err) {
this.$message.error('复制失败')
}
document.body.removeChild(ta)
}
}
}
</script>
<style scoped>
.mobile-zhongcao {
padding-bottom: 16px;
}
.box-card {
margin: 0 12px;
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
.main-form ::v-deep .el-form-item {
margin-bottom: 14px;
}
.btn-main {
margin-right: 8px;
}
.warn-alert {
margin-bottom: 16px;
}
.result-wrap {
margin-top: 8px;
}
.card-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.card-t {
font-weight: 600;
font-size: 14px;
}
.result-body {
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
padding: 12px;
background: #f9fafc;
border-radius: 4px;
max-height: 420px;
overflow-y: auto;
}
.extra-collapse {
margin-top: 16px;
}
.sub-block {
margin-bottom: 12px;
}
.pre-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
margin: 0;
padding: 8px;
background: #fafafa;
border-radius: 4px;
max-height: 240px;
overflow-y: auto;
}
@media (max-width: 768px) {
.box-card {
margin-left: 8px;
margin-right: 8px;
}
.btn-main {
width: 100%;
margin-right: 0;
margin-bottom: 8px;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="app-container">
<el-card>
<div slot="header" class="clearfix">
<span>日志文件查看</span>
<span class="hint">通过 HTTPS 轮询读取最新内容无需 SSH</span>
</div>
<el-form :inline="true" size="small" class="toolbar">
<el-form-item label="日志文件">
<el-select v-model="currentFile" placeholder="请选择" style="width: 180px" @change="handleFileChange">
<el-option
v-for="f in fileList"
:key="f"
:label="f"
:value="f"
/>
</el-select>
</el-form-item>
<el-form-item label="行数">
<el-input-number v-model="lines" :min="100" :max="5000" :step="100" style="width: 120px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-refresh" :loading="loading" @click="fetchLog">刷新</el-button>
</el-form-item>
<el-form-item>
<el-checkbox v-model="autoRefresh">自动刷新</el-checkbox>
</el-form-item>
<el-form-item v-if="autoRefresh">
<el-select v-model="refreshInterval" style="width: 100px" @change="restartTimer">
<el-option label="5 秒" :value="5" />
<el-option label="10 秒" :value="10" />
<el-option label="30 秒" :value="30" />
</el-select>
</el-form-item>
</el-form>
<div v-if="infoText" class="info-bar">
{{ infoText }}
</div>
<div class="log-wrap">
<pre ref="logPre" class="log-content">{{ logContent }}</pre>
</div>
</el-card>
</div>
</template>
<script>
import { listLogfiles, tailLogfile } from '@/api/monitor/logfile'
export default {
name: 'LogfileViewer',
data() {
return {
fileList: [],
currentFile: 'all.log',
lines: 200,
loading: false,
logContent: '',
totalLines: 0,
fromLine: 0,
toLine: 0,
autoRefresh: false,
refreshInterval: 10,
timer: null
}
},
computed: {
infoText() {
if (!this.currentFile || !this.totalLines) return ''
return `文件: ${this.currentFile} | 显示第 ${this.fromLine} - ${this.toLine} 行,共 ${this.totalLines}`
}
},
created() {
this.loadFileList()
},
beforeDestroy() {
this.stopTimer()
},
methods: {
loadFileList() {
listLogfiles().then(res => {
const list = res.data || []
this.fileList = Array.isArray(list) ? list : []
if (this.fileList.length && !this.fileList.includes(this.currentFile)) {
this.currentFile = this.fileList[0]
}
this.fetchLog()
}).catch(() => {
this.$message.error('获取日志列表失败')
})
},
handleFileChange() {
this.fetchLog()
},
fetchLog() {
if (!this.currentFile) return
this.loading = true
tailLogfile(this.currentFile, this.lines).then(res => {
const d = res.data || {}
this.logContent = d.content != null ? d.content : ''
this.totalLines = d.totalLines || 0
this.fromLine = d.fromLine || 0
this.toLine = d.toLine || 0
this.$nextTick(() => this.scrollToBottom())
}).catch(e => {
this.$message.error(e.msg || '读取日志失败')
this.logContent = ''
}).finally(() => {
this.loading = false
})
},
scrollToBottom() {
const el = this.$refs.logPre
if (el) el.scrollTop = el.scrollHeight
},
startTimer() {
this.stopTimer()
this.timer = setInterval(() => this.fetchLog(), this.refreshInterval * 1000)
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
restartTimer() {
if (this.autoRefresh) this.startTimer()
}
},
watch: {
autoRefresh(v) {
if (v) this.startTimer()
else this.stopTimer()
}
}
}
</script>
<style scoped>
.hint {
font-size: 12px;
color: #909399;
margin-left: 12px;
}
.toolbar {
margin-bottom: 12px;
}
.info-bar {
font-size: 12px;
color: #606266;
margin-bottom: 8px;
}
.log-wrap {
background: #1e1e1e;
border-radius: 4px;
padding: 12px;
max-height: 70vh;
overflow: auto;
}
.log-content {
margin: 0;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -171,23 +171,234 @@
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="12" class="card-box">
<el-card>
<div slot="header">
<span><i class="el-icon-truck"></i> 物流服务健康度</span>
</div>
<div class="el-table el-table--enable-row-hover el-table--medium">
<table cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">服务状态</div></td>
<td class="el-table__cell is-leaf">
<div class="cell">
<el-tag :type="health.logistics && health.logistics.healthy ? 'success' : 'danger'">
{{ health.logistics && health.logistics.status ? health.logistics.status : '未知' }}
</el-tag>
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">服务地址</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" style="word-break: break-all;">
{{ health.logistics && health.logistics.serviceUrl ? health.logistics.serviceUrl : '-' }}
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">状态信息</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" :class="{'text-danger': health.logistics && !health.logistics.healthy}">
{{ health.logistics && health.logistics.message ? health.logistics.message : '-' }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</el-card>
</el-col>
<el-col :span="12" class="card-box">
<el-card>
<div slot="header" class="clearfix">
<span><i class="el-icon-message"></i> 微信推送服务健康度</span>
<el-button
style="float: right; padding: 3px 10px;"
type="primary"
size="mini"
:loading="wxSendTesting"
@click="testWxSendHealth"
>
{{ wxSendTesting ? '检测中...' : '测试' }}
</el-button>
</div>
<div class="el-table el-table--enable-row-hover el-table--medium">
<table cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">服务状态</div></td>
<td class="el-table__cell is-leaf">
<div class="cell">
<el-tag :type="wxSendTagType">
{{ health.wxSend && health.wxSend.status ? health.wxSend.status : '未知' }}
</el-tag>
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">服务地址</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" style="word-break: break-all;">
{{ health.wxSend && health.wxSend.serviceUrl ? health.wxSend.serviceUrl : '-' }}
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">状态信息</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" :class="{'text-danger': health.wxSend && health.wxSend.healthy === false}">
{{ health.wxSend && health.wxSend.message ? health.wxSend.message : '-' }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</el-card>
</el-col>
<el-col :span="12" class="card-box">
<el-card>
<div slot="header" class="clearfix">
<span><i class="el-icon-bell"></i> 企微闲鱼通知wxSend</span>
<el-button
style="float: right; padding: 3px 10px;"
type="primary"
size="mini"
:loading="goofishTesting"
@click="testGoofishNotify"
>
{{ goofishTesting ? '检测中...' : '测试' }}
</el-button>
</div>
<div class="el-table el-table--enable-row-hover el-table--medium">
<table cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">服务状态</div></td>
<td class="el-table__cell is-leaf">
<div class="cell">
<el-tag :type="goofishTagType">
{{ health.goofishNotify && health.goofishNotify.status ? health.goofishNotify.status : '未知' }}
</el-tag>
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">推送接口</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" style="word-break: break-all;">
{{ health.goofishNotify && health.goofishNotify.serviceUrl ? health.goofishNotify.serviceUrl : '-' }}
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">状态信息</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" :class="{'text-danger': health.goofishNotify && health.goofishNotify.healthy === false}">
{{ health.goofishNotify && health.goofishNotify.message ? health.goofishNotify.message : '-' }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</el-card>
</el-col>
<!-- Ollama 服务健康度调试用 -->
<el-col :span="12" class="card-box">
<el-card>
<div slot="header" class="clearfix">
<span><i class="el-icon-cpu"></i> Ollama 服务健康度调试</span>
<el-button
style="float: right; padding: 3px 10px;"
type="primary"
size="mini"
:loading="ollamaTesting"
@click="testOllamaHealth"
>
{{ ollamaTesting ? '检测中...' : '测试' }}
</el-button>
</div>
<div class="el-table el-table--enable-row-hover el-table--medium">
<table cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">服务状态</div></td>
<td class="el-table__cell is-leaf">
<div class="cell">
<el-tag :type="health.ollama && health.ollama.healthy ? 'success' : (health.ollama ? 'danger' : 'info')">
{{ health.ollama && health.ollama.status ? health.ollama.status : '未检测' }}
</el-tag>
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">服务地址</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" style="word-break: break-all;">
{{ health.ollama && health.ollama.serviceUrl ? health.ollama.serviceUrl : '-' }}
</div>
</td>
</tr>
<tr>
<td class="el-table__cell is-leaf"><div class="cell">状态信息</div></td>
<td class="el-table__cell is-leaf">
<div class="cell" :class="{'text-danger': health.ollama && !health.ollama.healthy}">
{{ health.ollama && health.ollama.message ? health.ollama.message : '点击「测试」获取健康度' }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</el-card>
</el-col>
</el-row> </el-row>
</div> </div>
</template> </template>
<script> <script>
import { getServer } from "@/api/monitor/server" import { getServer, getHealth, triggerWxSendHealthTest, triggerGoofishNotifyTest } from "@/api/monitor/server"
export default { export default {
name: "Server", name: "Server",
data() { data() {
return { return {
// 服务器信息 // 服务器信息
server: [] server: [],
// 健康度检测信息
health: {
logistics: null,
wxSend: null,
goofishNotify: null,
ollama: null
},
ollamaTesting: false,
wxSendTesting: false,
goofishTesting: false
}
},
computed: {
wxSendTagType() {
const w = this.health.wxSend
if (!w || w.healthy === null || w.healthy === undefined) return 'info'
return w.healthy ? 'success' : 'danger'
},
goofishTagType() {
const g = this.health.goofishNotify
if (!g || g.healthy === null || g.healthy === undefined) return 'info'
return g.healthy ? 'success' : 'danger'
} }
}, },
created() { created() {
this.getList() this.getList()
this.getHealthInfo()
this.openLoading() this.openLoading()
}, },
methods: { methods: {
@@ -198,6 +409,91 @@ export default {
this.$modal.closeLoading() this.$modal.closeLoading()
}) })
}, },
/** 查询健康度检测信息 */
getHealthInfo() {
getHealth().then(response => {
if (response.data) {
this.health = response.data
}
}).catch(error => {
console.error("获取健康度检测信息失败", error)
})
},
/** 测试 Ollama 健康度(调试用) */
testOllamaHealth() {
this.ollamaTesting = true
getHealth()
.then(response => {
if (response.data) {
const prevWx = this.health.wxSend
const prevGf = this.health.goofishNotify
this.health = response.data
if (prevWx && prevWx.healthy !== null && prevWx.healthy !== undefined) {
this.$set(this.health, 'wxSend', prevWx)
}
if (prevGf && prevGf.healthy !== null && prevGf.healthy !== undefined) {
this.$set(this.health, 'goofishNotify', prevGf)
}
const ollama = response.data.ollama
if (ollama && ollama.healthy) {
this.$message.success('Ollama 服务正常')
} else {
this.$message.warning(ollama && ollama.message ? ollama.message : 'Ollama 服务异常或未配置')
}
}
})
.catch(error => {
console.error('Ollama 健康度检测失败', error)
this.$message.error('检测失败: ' + (error.message || '网络异常'))
})
.finally(() => {
this.ollamaTesting = false
})
},
/** 手动测试微信推送(会真实下发一条消息) */
testWxSendHealth() {
this.wxSendTesting = true
triggerWxSendHealthTest()
.then(response => {
if (response.data) {
this.$set(this.health, 'wxSend', response.data)
if (response.data.healthy) {
this.$message.success('微信推送测试成功')
} else {
this.$message.warning(response.data.message || '微信推送测试未通过')
}
}
})
.catch(error => {
console.error('微信推送测试失败', error)
this.$message.error('检测失败: ' + (error.message || '网络异常'))
})
.finally(() => {
this.wxSendTesting = false
})
},
/** 手动测试企微闲鱼通知 */
testGoofishNotify() {
this.goofishTesting = true
triggerGoofishNotifyTest()
.then(response => {
if (response.data) {
this.$set(this.health, 'goofishNotify', response.data)
if (response.data.healthy) {
this.$message.success('闲鱼通知测试成功')
} else {
this.$message.warning(response.data.message || '闲鱼通知测试未通过')
}
}
})
.catch(error => {
console.error('闲鱼通知测试失败', error)
this.$message.error('检测失败: ' + (error.message || '网络异常'))
})
.finally(() => {
this.goofishTesting = false
})
},
// 打开加载层 // 打开加载层
openLoading() { openLoading() {
this.$modal.loading("正在加载服务监控数据,请稍候!") this.$modal.loading("正在加载服务监控数据,请稍候!")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,528 @@
<template>
<div class="order-search-container">
<div class="search-card">
<div class="card-header">
<h3>订单搜索工具</h3>
<span class="header-desc">快速搜索下好的订单</span>
</div>
<div class="search-form">
<el-form :model="searchForm" label-width="100px" label-position="top">
<el-form-item label="单号搜索">
<el-input
v-model="searchForm.orderNo"
placeholder="请输入订单号/第三方单号/内部单号至少5个字符"
clearable
size="medium"
@keyup.enter.native="handleSearch"
@input="handleOrderNoInput"
/>
<div class="input-tip">
<i class="el-icon-info"></i>
至少输入5个字符将自动过滤TFHFPDD等搜索词
</div>
</el-form-item>
<el-form-item label="地址搜索">
<el-input
v-model="searchForm.address"
placeholder="请输入收货地址关键词至少3个字符"
clearable
size="medium"
@keyup.enter.native="handleSearch"
@input="handleAddressInput"
/>
<div class="input-tip">
<i class="el-icon-info"></i>
至少输入3个字符进行模糊搜索
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="medium"
icon="el-icon-search"
@click="handleSearch"
:loading="loading"
:disabled="!canSearch"
>
搜索
</el-button>
<el-button
size="medium"
icon="el-icon-refresh"
@click="handleReset"
>
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 搜索结果 -->
<div v-if="hasSearched" class="result-section">
<el-divider>
<span>搜索结果 {{ total }} </span>
</el-divider>
<div v-if="loading" class="loading-container">
<i class="el-icon-loading"></i>
<span>搜索中...</span>
</div>
<div v-else-if="orderList.length === 0" class="empty-container">
<el-empty description="未找到匹配的订单" />
</div>
<div v-else class="order-list">
<el-table
:data="orderList"
border
stripe
style="width: 100%"
:default-sort="{prop: 'createTime', order: 'descending'}"
>
<el-table-column label="内部单号" prop="remark" width="140" />
<el-table-column label="京东单号" prop="orderId" width="180" />
<el-table-column label="第三方单号" prop="thirdPartyOrderNo" width="150">
<template slot-scope="scope">
<span v-if="scope.row.thirdPartyOrderNo">{{ scope.row.thirdPartyOrderNo }}</span>
<span v-else style="color: #999;">-</span>
</template>
</el-table-column>
<el-table-column label="型号" prop="modelNumber" width="160" />
<el-table-column label="地址" prop="address" min-width="280" show-overflow-tooltip />
<el-table-column label="需要退款" width="100" align="center">
<template slot-scope="scope">
<el-tag
:type="scope.row.isRefunded === 1 ? 'warning' : 'info'"
size="small"
>
{{ scope.row.isRefunded === 1 ? '需要退款' : '正常订单' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="赔付/价保" prop="proPriceAmount" width="110" align="right">
<template slot-scope="scope">
{{ formatAmount(scope.row.proPriceAmount) }}
</template>
</el-table-column>
<el-table-column label="订单状态" prop="orderStatus" width="100" align="center">
<template slot-scope="scope">
<el-tag
v-if="scope.row.orderStatus != null"
:type="getOrderStatusType(scope.row.orderStatus)"
size="small"
>
{{ getOrderStatusText(scope.row.orderStatus) }}
</el-tag>
<span v-else style="color: #999;">-</span>
</template>
</el-table-column>
<el-table-column label="备注" prop="status" min-width="120" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createTime" width="160">
<template slot-scope="scope">
{{ parseTime(scope.row.createTime) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="queryParams.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size="queryParams.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
/>
</div>
</div>
</div>
</div>
<!-- 页尾导航 -->
<PublicFooterNav />
</div>
</template>
<script>
import { listJDOrders, searchOrders } from '@/api/system/jdorder'
import { parseTime as formatTime } from '@/utils/ruoyi'
import PublicFooterNav from '@/components/PublicFooterNav'
export default {
name: 'OrderSearch',
components: {
PublicFooterNav
},
data() {
return {
searchForm: {
orderNo: '',
address: ''
},
loading: false,
hasSearched: false,
orderList: [],
total: 0,
queryParams: {
pageNum: 1,
pageSize: 20
},
// 需要过滤的搜索词
filteredKeywords: ['TF', 'H', 'F', 'PDD']
}
},
computed: {
canSearch() {
const orderNo = (this.searchForm.orderNo || '').trim()
const address = (this.searchForm.address || '').trim()
// 至少有一个搜索条件满足要求
const orderNoValid = orderNo.length >= 5 && !this.isFilteredKeyword(orderNo)
const addressValid = address.length >= 3
return orderNoValid || addressValid
}
},
methods: {
// 检查是否是过滤的关键词
isFilteredKeyword(keyword) {
const upperKeyword = keyword.toUpperCase()
return this.filteredKeywords.some(kw => upperKeyword.includes(kw.toUpperCase()))
},
// 处理单号输入
handleOrderNoInput(value) {
// 如果输入的是过滤关键词,清空
if (value && value.length < 5) {
return
}
if (this.isFilteredKeyword(value)) {
this.$message.warning('该搜索词已被过滤,请输入其他关键词')
this.searchForm.orderNo = ''
}
},
// 处理地址输入
handleAddressInput(value) {
// 地址输入不需要特殊处理
},
// 执行搜索
async handleSearch() {
if (!this.canSearch) {
this.$message.warning('请输入有效的搜索条件')
return
}
const orderNo = (this.searchForm.orderNo || '').trim()
const address = (this.searchForm.address || '').trim()
// 验证搜索条件
if (orderNo && (orderNo.length < 5 || this.isFilteredKeyword(orderNo))) {
this.$message.warning('单号搜索至少需要5个字符且不能包含TF、H、F、PDD等关键词')
return
}
if (address && address.length < 3) {
this.$message.warning('地址搜索至少需要3个字符')
return
}
if (!orderNo && !address) {
this.$message.warning('请至少输入一个搜索条件')
return
}
this.loading = true
this.hasSearched = true
this.queryParams.pageNum = 1
try {
// 构建查询参数
const queryParams = {
pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize,
orderSearch: orderNo || undefined,
address: address || undefined
}
// 使用专门的搜索接口
const res = await searchOrders(queryParams)
console.log('搜索响应:', res) // 调试用
// 判断响应是否成功(兼容多种响应格式)
const isSuccess = res && (
res.code === 200 ||
res.code === 0 ||
res.msg === '操作成功' ||
res.msg === '查询成功' ||
(res.rows && Array.isArray(res.rows))
)
if (isSuccess) {
const list = (res.rows || res.data || [])
console.log('解析到的列表:', list) // 调试用
// 处理退款相关字段的默认值
this.orderList = list.map(item => ({
...item,
isRefunded: item.isRefunded != null ? item.isRefunded : 0,
isRefundReceived: item.isRefundReceived != null ? item.isRefundReceived : 0,
isRebateReceived: item.isRebateReceived != null ? item.isRebateReceived : 0
}))
this.total = res.total || 0
console.log('最终订单列表:', this.orderList) // 调试用
console.log('总数:', this.total) // 调试用
if (this.orderList.length === 0) {
this.$message.info('未找到匹配的订单')
} else {
this.$message.success(`找到 ${this.total} 条匹配的订单`)
}
} else {
console.error('响应判断失败:', res) // 调试用
this.$message.error(res && res.msg ? res.msg : '搜索失败')
this.orderList = []
this.total = 0
}
} catch (error) {
console.error('搜索失败:', error)
this.$message.error('搜索失败,请稍后重试')
this.orderList = []
this.total = 0
} finally {
this.loading = false
}
},
// 重置搜索
handleReset() {
this.searchForm = {
orderNo: '',
address: ''
}
this.orderList = []
this.total = 0
this.hasSearched = false
this.queryParams.pageNum = 1
this.queryParams.pageSize = 20
},
// 分页大小变化
handleSizeChange(val) {
this.queryParams.pageSize = val
this.queryParams.pageNum = 1
this.handleSearch()
},
// 当前页变化
handleCurrentChange(val) {
this.queryParams.pageNum = val
this.handleSearch()
},
// 格式化金额
formatAmount(amount) {
if (amount == null || amount === '') return '-'
const num = Number(amount)
if (Number.isNaN(num)) return amount
return num.toFixed(2)
},
// 解析时间
parseTime(time) {
if (!time) return '-'
return formatTime(time, '{y}-{m}-{d} {h}:{i}:{s}')
},
// 获取订单状态文本
getOrderStatusText(status) {
if (status == null) return '-'
const statusMap = {
'-100': '无变化',
'-1': '未知',
2: '无效-拆单',
3: '无效-取消',
4: '无效-京东帮帮主订单',
5: '无效-账号异常',
6: '无效-赠品类目不返佣',
7: '无效-校园订单',
8: '无效-企业订单',
9: '无效-团购订单',
11: '无效-乡村推广员下单',
13: '违规订单-其他',
14: '无效-来源与备案网址不符',
15: '待付款',
16: '已付款',
17: '已完成',
19: '无效-佣金比例为0',
20: '无效-此复购订单对应的首购订单无效',
21: '无效-云店订单',
22: '无效-PLUS会员佣金比例为0',
23: '无效-支付有礼',
24: '已付定金',
25: '违规订单-流量劫持',
26: '违规订单-流量异常',
27: '违规订单-违反京东平台规则',
28: '违规订单-多笔交易异常',
29: '无效-跨屏跨店',
30: '无效-累计件数超出类目上限',
31: '无效-黑名单sku',
33: '超市卡充值订单',
34: '无效-推卡订单无效'
}
return statusMap[status] || `状态${status}`
},
// 获取订单状态类型
getOrderStatusType(status) {
if (status == null) return 'info'
// 取消状态(优先级最高)
if (status === 3) return 'danger' // 无效-取消(红色,优先级高于违规)
// 正常状态
if (status === 16) return 'success' // 已付款
if (status === 17) return 'success' // 已完成
if (status === 15) return 'warning' // 待付款
if (status === 24) return 'warning' // 已付定金
// 违规状态
if ([13, 25, 26, 27, 28].includes(status)) return 'warning' // 违规订单(黄色,优先级低于取消)
// 无效状态
if ([2, 4, 5, 6, 7, 8, 9, 11, 14, 19, 20, 21, 22, 23, 29, 30, 31, 34].includes(status)) return 'info' // 无效订单(灰色)
// 其他状态
return 'info'
}
}
}
</script>
<style scoped>
.order-search-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
padding-bottom: calc(80px + 20px); /* 为页尾导航留出空间 */
display: flex;
align-items: flex-start;
justify-content: center;
}
.search-card {
max-width: 1400px;
width: 100%;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #409EFF 0%, #67C23A 100%);
color: #fff;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-desc {
font-size: 14px;
opacity: 0.9;
}
.search-form {
padding: 24px;
}
.input-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 4px;
}
.input-tip i {
color: #409EFF;
}
.result-section {
padding: 0 24px 24px;
}
.loading-container {
text-align: center;
padding: 40px;
color: #909399;
}
.loading-container i {
font-size: 24px;
margin-right: 8px;
animation: rotating 2s linear infinite;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.empty-container {
padding: 40px;
}
.order-list {
margin-top: 16px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* 响应式设计 */
@media (max-width: 768px) {
.order-search-container {
padding: 12px;
}
.search-card {
border-radius: 8px;
}
.card-header {
padding: 16px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.card-header h3 {
font-size: 18px;
}
.search-form {
padding: 16px;
}
.result-section {
padding: 0 16px 16px;
}
.pagination-container {
overflow-x: auto;
}
}
</style>

View File

@@ -0,0 +1,241 @@
<template>
<div class="public-home-container">
<div class="home-content">
<div class="header-section">
<h1 class="main-title">工具中心</h1>
<p class="subtitle">选择您需要的功能</p>
</div>
<div class="tools-grid">
<div
v-for="tool in tools"
:key="tool.path"
class="tool-card"
@click="handleToolClick(tool.path)"
>
<div class="tool-icon-wrapper">
<i :class="tool.icon"></i>
</div>
<h3 class="tool-title">{{ tool.title }}</h3>
<p class="tool-desc">{{ tool.description }}</p>
<div class="tool-footer">
<span class="tool-action">点击使用 <i class="el-icon-arrow-right"></i></span>
</div>
</div>
</div>
</div>
<!-- 页尾导航 -->
<PublicFooterNav />
</div>
</template>
<script>
import PublicFooterNav from '@/components/PublicFooterNav'
export default {
name: 'PublicHome',
components: {
PublicFooterNav
},
data() {
return {
tools: [
{
title: '评论生成',
description: '快速生成产品评论内容',
path: '/tools/comment-gen',
icon: 'el-icon-edit-outline'
},
{
title: '订单提交',
description: '提交订单信息,录入系统',
path: '/public/order-submit',
icon: 'el-icon-upload2'
},
{
title: '订单搜索',
description: '快速搜索订单信息',
path: '/tools/order-search',
icon: 'el-icon-search'
}
]
}
},
methods: {
handleToolClick(path) {
this.$router.push(path)
}
}
}
</script>
<style scoped>
.public-home-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px 80px;
display: flex;
align-items: flex-start;
justify-content: center;
}
.home-content {
max-width: 1200px;
width: 100%;
}
.header-section {
text-align: center;
margin-bottom: 40px;
color: #fff;
}
.main-title {
font-size: 36px;
font-weight: 700;
margin: 0 0 12px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.subtitle {
font-size: 18px;
margin: 0;
opacity: 0.9;
}
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.tool-card {
background: #fff;
border-radius: 16px;
padding: 32px 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.tool-card:hover {
transform: translateY(-8px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
}
.tool-icon-wrapper {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #409EFF 0%, #67C23A 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
transition: transform 0.3s ease;
}
.tool-card:hover .tool-icon-wrapper {
transform: scale(1.1) rotate(5deg);
}
.tool-icon-wrapper i {
font-size: 40px;
color: #fff;
}
.tool-title {
font-size: 22px;
font-weight: 600;
color: #303133;
margin: 0 0 12px 0;
}
.tool-desc {
font-size: 14px;
color: #909399;
line-height: 1.6;
margin: 0 0 20px 0;
flex: 1;
}
.tool-footer {
width: 100%;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.tool-action {
font-size: 14px;
color: #409eff;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tool-action i {
transition: transform 0.3s ease;
}
.tool-card:hover .tool-action i {
transform: translateX(4px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.public-home-container {
padding: 24px 16px 80px;
}
.main-title {
font-size: 28px;
}
.subtitle {
font-size: 16px;
}
.tools-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.tool-card {
padding: 24px 20px;
}
.tool-icon-wrapper {
width: 70px;
height: 70px;
}
.tool-icon-wrapper i {
font-size: 36px;
}
.tool-title {
font-size: 20px;
}
}
@media (max-width: 480px) {
.main-title {
font-size: 24px;
}
.subtitle {
font-size: 14px;
}
.tool-card {
padding: 20px 16px;
}
}
</style>

View File

@@ -0,0 +1,501 @@
<template>
<div class="public-order-container">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>订单提交台</span>
<span class="header-desc">请按照格式提交订单信息</span>
</div>
<el-form :model="form" label-width="80px" label-position="top">
<el-form-item>
<template slot="label">
<span>输入订单信息</span>
<el-tag type="warning" size="mini" style="margin-left: 10px;">只能提交今天的订单</el-tag>
</template>
<el-input
v-model="form.command"
type="textarea"
:rows="12"
:placeholder="getPlaceholder()"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitOrder" :loading="loading" size="medium">
<i class="el-icon-upload"></i> 提交订单
</el-button>
<el-button @click="clearAll" size="medium">
<i class="el-icon-delete"></i> 清空
</el-button>
</el-form-item>
</el-form>
<el-divider>响应结果</el-divider>
<div v-if="resultList.length === 0" style="padding: 12px 0;">
<el-empty description="暂无响应" />
</div>
<div v-else>
<div v-for="(msg, idx) in resultList" :key="idx" class="msg-block">
<div class="msg-header">
<span> {{ idx + 1 }} </span>
<el-button size="mini" type="success" @click="copyOne(msg)">复制此段</el-button>
</div>
<el-input :value="msg" type="textarea" :rows="8" readonly />
</div>
<div style="margin-top: 8px;">
<el-button size="mini" type="primary" @click="copyAll">复制全部</el-button>
</div>
</div>
<!-- 使用说明 -->
<el-divider>使用说明</el-divider>
<div class="usage-guide">
<el-collapse>
<el-collapse-item title="订单格式说明" name="1">
<div class="guide-content">
<p><strong>请严格按照以下格式填写订单信息</strong></p>
<pre class="format-example">
{{ getTodayDate() }} 001
备注测试订单
分销标记H-TF
型号ZQD180F-EB200
链接https://item.jd.com/...
下单付款1650
后返金额50
地址张三13800138000上海市浦东新区张江高科技园区...
物流链接https://...
订单号1234567890
下单人张三</pre>
<p class="tips"><i class="el-icon-warning"></i> 重要提示订单日期必须是今天{{ getTodayDate() }}每个字段都不能省略</p>
</div>
</el-collapse-item>
<el-collapse-item title="注意事项" name="2">
<div class="guide-content">
<ul>
<li><strong style="color: #E6A23C;">只能提交今天的订单历史订单不允许提交</strong></li>
<li>请确保订单信息准确无误</li>
<li>每次只能提交一个订单</li>
<li>提交成功后会显示确认信息</li>
<li>如遇错误请检查格式和日期是否正确</li>
<li>限流策略每半小时最多提交120个订单</li>
</ul>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
<!-- 地址重复验证码弹窗 -->
<el-dialog
title="地址重复验证"
:visible.sync="verifyDialogVisible"
width="400px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<div style="text-align: center;">
<el-alert
:title="verifyMessage"
type="warning"
:closable="false"
style="margin-bottom: 20px;"
/>
<div style="font-size: 24px; font-weight: bold; color: #409EFF; margin: 20px 0;">
{{ verifyCode }}
</div>
<el-input
v-model="verifyInput"
placeholder="请输入上方四位数字验证码"
maxlength="4"
style="width: 200px;"
@keyup.enter.native="handleVerify"
/>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="verifyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleVerify" :loading="verifyLoading">确认</el-button>
</div>
</el-dialog>
<!-- 页尾导航 -->
<PublicFooterNav />
</div>
</template>
<script>
import { submitPublicOrder, submitPublicOrderWithForce } from '@/api/public/order'
import PublicFooterNav from '@/components/PublicFooterNav'
export default {
name: 'PublicOrderSubmit',
components: {
PublicFooterNav
},
data() {
return {
form: { command: '' },
loading: false,
resultList: [],
// 验证码相关
verifyDialogVisible: false,
verifyCode: '',
verifyInput: '',
verifyMessage: '',
verifyLoading: false,
pendingCommand: '' // 待执行的命令(验证通过后执行)
}
},
methods: {
getPlaceholder() {
const today = new Date().toISOString().split('T')[0]
return `请按照以下格式输入订单信息(注意:订单日期必须是今天 ${today}
单:
${today} 001
备注:测试订单
分销标记H-TF
型号ZQD180F-EB200
链接https://...
下单付款1650
后返金额50
地址张三13800138000上海市浦东新区...
物流链接https://...
订单号1234567890
下单人:张三`
},
copyOne(text) {
if (!text) return
this.doCopy(text)
},
copyAll() {
if (!this.resultList || this.resultList.length === 0) return
const text = this.resultList.join('\n\n')
this.doCopy(text)
},
doCopy(text) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
this.$message.success('复制成功')
}).catch(() => {
this.fallbackCopyText(text)
})
} else {
this.fallbackCopyText(text)
}
},
fallbackCopyText(text) {
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.focus()
ta.select()
try {
document.execCommand('copy')
this.$message.success('复制成功')
} catch (e) {
this.$message.error('复制失败')
}
document.body.removeChild(ta)
},
submitOrder() {
const cmd = (this.form.command || '').trim()
if (!cmd) {
this.$message.error('请输入订单信息')
return
}
// 检查是否以"单:"开头
if (!cmd.startsWith('单:') && !cmd.startsWith('单:')) {
this.$message.error('订单信息必须以"单:"开头')
return
}
this.loading = true
submitPublicOrder({ command: cmd }).then(res => {
this.loading = false
if (res && (res.code === 200 || res.msg === '操作成功')) {
const data = res.data
if (Array.isArray(data)) {
this.resultList = data
} else if (typeof data === 'string') {
this.resultList = data ? [data] : []
} else {
this.resultList = []
}
// 检查是否是地址重复错误
if (this.checkAddressDuplicate(this.resultList)) {
// 显示验证码弹窗
this.showVerifyDialog(cmd)
return
}
// 检查是否有警告信息
this.checkWarningAlert(this.resultList)
// 如果没有警告,显示成功提示
if (!this.hasWarning(this.resultList)) {
this.$message.success('订单提交成功')
}
} else {
this.$message.error(res && res.msg ? res.msg : '提交失败')
this.resultList = []
}
}).catch(error => {
this.loading = false
const errorMsg = error.response?.data?.msg || error.message || '提交失败,请稍后重试'
this.$message.error(errorMsg)
this.resultList = []
})
},
// 检查是否是地址重复错误
checkAddressDuplicate(resultList) {
if (!resultList || resultList.length === 0) return false
for (let i = 0; i < resultList.length; i++) {
const result = resultList[i]
if (typeof result === 'string' && result.startsWith('ERROR_CODE:ADDRESS_DUPLICATE')) {
return true
}
}
return false
},
// 显示验证码弹窗
showVerifyDialog(command) {
// 生成四位随机数字验证码
this.verifyCode = String(Math.floor(1000 + Math.random() * 9000))
this.verifyInput = ''
this.verifyMessage = '检测到地址重复,请输入验证码以强制生成表单'
this.pendingCommand = command
this.verifyDialogVisible = true
},
// 处理验证码验证
handleVerify() {
if (!this.verifyInput || this.verifyInput.length !== 4) {
this.$message.error('请输入四位数字验证码')
return
}
if (this.verifyInput !== this.verifyCode) {
this.$message.error('验证码错误,请重新输入')
this.verifyInput = ''
return
}
// 验证通过使用forceGenerate参数重新提交
this.verifyLoading = true
submitPublicOrderWithForce({ command: this.pendingCommand }).then(res => {
this.verifyLoading = false
this.verifyDialogVisible = false
if (res && (res.code === 200 || res.msg === '操作成功')) {
const data = res.data
if (Array.isArray(data)) {
this.resultList = data
} else if (typeof data === 'string') {
this.resultList = data ? [data] : []
} else {
this.resultList = []
}
// 检查是否有警告信息
this.checkWarningAlert(this.resultList)
// 如果没有警告,显示成功提示
if (!this.hasWarning(this.resultList)) {
this.$message.success('订单提交成功(已强制生成)')
}
} else {
this.$message.error(res && res.msg ? res.msg : '提交失败')
this.resultList = []
}
}).catch(error => {
this.verifyLoading = false
const errorMsg = error.response?.data?.msg || error.message || '提交失败,请稍后重试'
this.$message.error(errorMsg)
this.resultList = []
})
},
clearAll() {
this.form.command = ''
this.resultList = []
},
checkWarningAlert(resultList) {
if (!resultList || resultList.length === 0) return
// 检查是否有以[炸弹]开头的警告消息
const warningMessages = resultList
.filter(msg => {
return msg && typeof msg === 'string' && msg.trim().includes('[炸弹]')
})
.map(msg => {
// 移除所有的[炸弹]标记
return msg.trim().replace(/\[炸弹\]\s*/g, '').trim()
})
if (warningMessages.length > 0) {
// 显示警告弹窗
this.$alert(warningMessages.join('\n\n'), '⚠️ 警告提示', {
confirmButtonText: '我已知晓',
type: 'warning',
center: true,
customClass: 'warning-alert-dialog',
showClose: false,
closeOnClickModal: false,
closeOnPressEscape: false,
dangerouslyUseHTMLString: false
}).catch(() => {})
}
},
hasWarning(resultList) {
if (!resultList || resultList.length === 0) return false
return resultList.some(msg => msg && typeof msg === 'string' && msg.trim().includes('[炸弹]'))
},
getTodayDate() {
return new Date().toISOString().split('T')[0]
}
}
}
</script>
<style scoped>
.public-order-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
padding-bottom: calc(80px + 20px); /* 为页尾导航留出空间 */
display: flex;
align-items: center;
justify-content: center;
}
.box-card {
max-width: 1000px;
width: 100%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.clearfix {
display: flex;
align-items: center;
justify-content: space-between;
}
.clearfix span:first-child {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.header-desc {
font-size: 12px;
color: #909399;
font-weight: normal;
}
.msg-block {
margin-bottom: 12px;
}
.msg-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 6px 0;
font-weight: 500;
color: #606266;
}
.usage-guide {
margin-top: 20px;
}
.guide-content {
padding: 10px;
}
.guide-content p {
margin: 10px 0;
line-height: 1.8;
color: #606266;
}
.format-example {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
border-left: 3px solid #409eff;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
margin: 15px 0;
}
.tips {
color: #e6a23c;
font-size: 13px;
padding: 10px;
background-color: #fdf6ec;
border-radius: 4px;
border: 1px solid #f5dab1;
}
.guide-content ul {
list-style: none;
padding: 0;
}
.guide-content ul li {
padding: 8px 0;
color: #606266;
line-height: 1.6;
}
.guide-content ul li:before {
content: "•";
color: #409eff;
font-weight: bold;
display: inline-block;
width: 1em;
margin-left: 10px;
}
</style>
<style>
/* 全局样式:警告弹窗 */
.warning-alert-dialog {
width: 80vw !important;
max-width: 800px !important;
min-width: 400px !important;
}
.warning-alert-dialog .el-message-box__header {
padding: 20px 20px 15px !important;
}
.warning-alert-dialog .el-message-box__title {
font-size: 18px !important;
font-weight: 700 !important;
}
.warning-alert-dialog .el-message-box__message {
font-size: 15px !important;
font-weight: 600 !important;
color: #e6a23c !important;
white-space: pre-wrap !important;
word-break: break-word !important;
line-height: 1.8 !important;
max-height: 60vh !important;
overflow-y: auto !important;
padding: 15px 20px !important;
}
.warning-alert-dialog .el-message-box__btns {
text-align: center !important;
padding: 15px 20px 20px !important;
}
.warning-alert-dialog .el-message-box__btns .el-button {
padding: 12px 40px !important;
font-size: 14px !important;
font-weight: 600 !important;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div style="padding: 40px; text-align: center; font-family: Arial, sans-serif;">
<div v-if="loading" style="margin-top: 100px;">
<i class="el-icon-loading" style="font-size: 48px; color: #409EFF;"></i>
<p style="margin-top: 20px; font-size: 16px; color: #666;">正在处理授权...</p>
</div>
<div v-else-if="success" style="margin-top: 100px;">
<i class="el-icon-success" style="font-size: 64px; color: #67C23A;"></i>
<h2 style="margin-top: 20px; color: #67C23A;">授权成功</h2>
<div style="margin-top: 30px; padding: 20px; background: #f5f7fa; border-radius: 8px; max-width: 600px; margin-left: auto; margin-right: auto;">
<p style="margin-bottom: 15px; font-size: 14px; color: #666;">请复制以下访问令牌</p>
<el-input
:value="accessToken"
readonly
style="margin-bottom: 15px;"
>
<template slot="append">
<el-button @click="copyToken" icon="el-icon-document-copy">复制</el-button>
</template>
</el-input>
<p style="font-size: 12px; color: #999; margin-top: 10px;">
访问令牌有效期{{ expiresIn }}
</p>
</div>
<div style="margin-top: 30px;">
<el-button type="primary" @click="closeWindow">关闭窗口</el-button>
</div>
</div>
<div v-else style="margin-top: 100px;">
<i class="el-icon-error" style="font-size: 64px; color: #F56C6C;"></i>
<h2 style="margin-top: 20px; color: #F56C6C;">授权失败</h2>
<p style="margin-top: 20px; color: #666;">{{ errorMessage }}</p>
<div style="margin-top: 30px;">
<el-button type="primary" @click="closeWindow">关闭窗口</el-button>
</div>
</div>
</div>
</template>
<script>
import { getTencentDocAccessToken } from '@/api/jarvis/tendoc'
export default {
name: 'TencentDocCallback',
data() {
return {
loading: true,
success: false,
accessToken: '',
refreshToken: '',
expiresIn: 0,
errorMessage: ''
}
},
created() {
this.handleCallback()
},
methods: {
async handleCallback() {
// 从URL参数中获取code和state
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const state = urlParams.get('state')
const error = urlParams.get('error')
const errorDescription = urlParams.get('error_description')
// 处理授权错误
if (error) {
this.loading = false
this.errorMessage = errorDescription || error || '授权失败'
return
}
// 验证授权码
if (!code) {
this.loading = false
this.errorMessage = '未收到授权码,请重新授权'
return
}
try {
// 调用后端接口获取访问令牌
const res = await getTencentDocAccessToken(code)
if (res.code === 200 && res.data) {
const data = res.data
this.accessToken = data.access_token || data.accessToken || ''
this.refreshToken = data.refresh_token || data.refreshToken || ''
this.expiresIn = data.expires_in || data.expiresIn || 0
// 保存到localStorage供主窗口使用
if (this.accessToken) {
localStorage.setItem('tendoc_access_token', this.accessToken)
if (this.refreshToken) {
localStorage.setItem('tendoc_refresh_token', this.refreshToken)
}
// 通知父窗口(如果是从弹窗打开的)
if (window.opener) {
window.opener.postMessage({
type: 'tendoc_auth_success',
access_token: this.accessToken,
refresh_token: this.refreshToken,
expires_in: this.expiresIn
}, '*')
}
}
this.success = true
} else {
this.errorMessage = res.msg || '获取访问令牌失败'
}
} catch (e) {
console.error('获取访问令牌失败', e)
this.errorMessage = e.message || '获取访问令牌失败,请稍后重试'
} finally {
this.loading = false
}
},
copyToken() {
if (this.accessToken) {
if (navigator.clipboard) {
navigator.clipboard.writeText(this.accessToken).then(() => {
this.$message.success('访问令牌已复制到剪贴板')
}).catch(() => {
this.fallbackCopy(this.accessToken)
})
} else {
this.fallbackCopy(this.accessToken)
}
}
},
fallbackCopy(text) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
this.$message.success('访问令牌已复制到剪贴板')
} catch (err) {
this.$message.error('复制失败,请手动复制')
}
document.body.removeChild(textArea)
},
closeWindow() {
if (window.opener) {
window.close()
} else {
// 如果不是弹窗,跳转到订单列表页面
this.$router.push('/system/jdorder/orderList')
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,689 @@
<template>
<div class="app-container">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 京东评论标签页 -->
<el-tab-pane label="京东评论" name="jd">
<div class="comment-container">
<!-- 搜索区域 -->
<mobile-search-form
:model="jdQueryParams"
@search="handleJdQuery"
@reset="resetJdQuery"
>
<template #form="{ expanded }">
<el-form
:inline="true"
:model="jdQueryParams"
class="demo-form-inline"
size="small"
label-width="68px"
>
<el-form-item label="商品ID">
<el-input v-model="jdQueryParams.productId" placeholder="商品ID" clearable @keyup.enter.native="handleJdQuery" />
</el-form-item>
<el-form-item label="产品类型">
<el-select v-model="jdQueryParams.productType" placeholder="请选择" clearable filterable style="width: 200px;">
<el-option
v-for="(value, key) in jdProductTypeMap"
:key="key"
:label="key"
:value="key">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="jdQueryParams.userName" placeholder="用户名" clearable @keyup.enter.native="handleJdQuery" />
</el-form-item>
<el-form-item label="使用状态">
<el-select v-model="jdQueryParams.isUse" placeholder="全部" clearable style="width: 120px;">
<el-option label="未使用" :value="0" />
<el-option label="已使用" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="jdDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
@change="handleJdDateRangeChange"
/>
</el-form-item>
<!-- 桌面端搜索按钮 -->
<el-form-item v-if="!expanded">
<el-button type="primary" icon="el-icon-search" size="small" @click="handleJdQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="small" @click="resetJdQuery">重置</el-button>
<el-button type="warning" icon="el-icon-upload2" size="small" :loading="jdFetchLoading" @click="handleJdFetchComments">获取评论</el-button>
<el-button type="success" icon="el-icon-download" size="small" @click="handleJdExport">导出</el-button>
</el-form-item>
</el-form>
</template>
</mobile-search-form>
<!-- 操作按钮区域移动端单独显示 -->
<div class="action-buttons-section mobile-only">
<mobile-button-group
:buttons="jdActionButtons"
:primary-count="2"
/>
</div>
<!-- 桌面端按钮组 -->
<div class="desktop-action-buttons desktop-only">
<el-button type="warning" icon="el-icon-upload2" size="small" :loading="jdFetchLoading" @click="handleJdFetchComments">获取评论</el-button>
<el-button type="success" icon="el-icon-download" size="small" @click="handleJdExport">导出</el-button>
</div>
<!-- 表格区域 -->
<el-table v-loading="jdLoading" :data="jdList" border>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="产品类型" prop="productType" width="150" />
<el-table-column label="商品ID" prop="productId" width="200" />
<el-table-column label="用户名" prop="userName" width="120" />
<el-table-column label="评论内容" prop="commentText" min-width="300" show-overflow-tooltip />
<el-table-column label="评论ID" prop="commentId" width="150" />
<el-table-column label="图片" width="100" align="center">
<template slot-scope="scope">
<el-button v-if="scope.row.pictureUrls" type="text" @click="viewImages(scope.row.pictureUrls)">查看图片</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="使用状态" prop="isUse" width="100" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.isUse === 0 ? 'success' : 'info'" size="small">
{{ scope.row.isUse === 0 ? '未使用' : '已使用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createdAt" width="180" align="center">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createdAt, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="200" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
:type="scope.row.isUse === 0 ? 'warning' : 'success'"
@click="toggleJdCommentUse(scope.row)"
>
{{ scope.row.isUse === 0 ? '标记已使用' : '标记未使用' }}
</el-button>
<el-button
size="mini"
type="danger"
@click="handleJdDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-show="jdTotal > 0"
:total="jdTotal"
:page.sync="jdQueryParams.pageNum"
:limit.sync="jdQueryParams.pageSize"
@pagination="getJdList"
/>
</div>
</el-tab-pane>
<!-- 淘宝评论标签页 -->
<el-tab-pane label="淘宝评论" name="tb">
<div class="comment-container">
<!-- 搜索区域 -->
<mobile-search-form
:model="tbQueryParams"
@search="handleTbQuery"
@reset="resetTbQuery"
>
<template #form="{ expanded }">
<el-form
:inline="true"
:model="tbQueryParams"
class="demo-form-inline"
size="small"
label-width="68px"
>
<el-form-item label="商品ID">
<el-input v-model="tbQueryParams.productId" placeholder="商品ID" clearable @keyup.enter.native="handleTbQuery" />
</el-form-item>
<el-form-item label="产品类型">
<el-select v-model="tbQueryParams.productType" placeholder="请选择" clearable filterable style="width: 200px;">
<el-option
v-for="(value, key) in tbProductTypeMap"
:key="key"
:label="key"
:value="key">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="tbQueryParams.userName" placeholder="用户名" clearable @keyup.enter.native="handleTbQuery" />
</el-form-item>
<el-form-item label="使用状态">
<el-select v-model="tbQueryParams.isUse" placeholder="全部" clearable style="width: 120px;">
<el-option label="未使用" :value="0" />
<el-option label="已使用" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="tbDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
@change="handleTbDateRangeChange"
/>
</el-form-item>
<!-- 桌面端搜索按钮 -->
<el-form-item v-if="!expanded">
<el-button type="primary" icon="el-icon-search" size="small" @click="handleTbQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="small" @click="resetTbQuery">重置</el-button>
<el-button type="warning" icon="el-icon-upload2" size="small" :loading="tbFetchLoading" @click="handleTbFetchComments">获取评论</el-button>
<el-button type="success" icon="el-icon-download" size="small" @click="handleTbExport">导出</el-button>
</el-form-item>
</el-form>
</template>
</mobile-search-form>
<!-- 操作按钮区域移动端单独显示 -->
<div class="action-buttons-section mobile-only">
<mobile-button-group
:buttons="tbActionButtons"
:primary-count="2"
/>
</div>
<!-- 桌面端按钮组 -->
<div class="desktop-action-buttons desktop-only">
<el-button type="warning" icon="el-icon-upload2" size="small" :loading="tbFetchLoading" @click="handleTbFetchComments">获取评论</el-button>
<el-button type="success" icon="el-icon-download" size="small" @click="handleTbExport">导出</el-button>
</div>
<!-- 表格区域 -->
<el-table v-loading="tbLoading" :data="tbList" border>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="产品类型" prop="productType" width="150" />
<el-table-column label="商品ID" prop="productId" width="200" />
<el-table-column label="用户名" prop="userName" width="120" />
<el-table-column label="评论内容" prop="commentText" min-width="300" show-overflow-tooltip />
<el-table-column label="评论ID" prop="commentId" width="150" />
<el-table-column label="图片" width="100" align="center">
<template slot-scope="scope">
<el-button v-if="scope.row.pictureUrls" type="text" @click="viewImages(scope.row.pictureUrls)">查看图片</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="使用状态" prop="isUse" width="100" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.isUse === 0 ? 'success' : 'info'" size="small">
{{ scope.row.isUse === 0 ? '未使用' : '已使用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createdAt" width="180" align="center">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createdAt, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="200" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
:type="scope.row.isUse === 0 ? 'warning' : 'success'"
@click="toggleTbCommentUse(scope.row)"
>
{{ scope.row.isUse === 0 ? '标记已使用' : '标记未使用' }}
</el-button>
<el-button
size="mini"
type="danger"
@click="handleTbDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-show="tbTotal > 0"
:total="tbTotal"
:page.sync="tbQueryParams.pageNum"
:limit.sync="tbQueryParams.pageSize"
@pagination="getTbList"
/>
</div>
</el-tab-pane>
<!-- 统计信息标签页 -->
<el-tab-pane label="统计信息" name="statistics">
<div class="statistics-container">
<el-form :inline="true" :model="statQueryParams" class="demo-form-inline" size="small">
<el-form-item label="评论来源">
<el-select v-model="statQueryParams.source" placeholder="全部" clearable style="width: 150px;">
<el-option label="京东评论" value="jd" />
<el-option label="淘宝评论" value="tb" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="small" @click="getStatistics">查询</el-button>
<el-button icon="el-icon-refresh" size="small" @click="resetStatistics">重置</el-button>
</el-form-item>
</el-form>
<!-- 统计表格 -->
<el-table v-loading="statLoading" :data="statisticsList" border style="margin-top: 20px;">
<el-table-column label="来源" prop="source" width="120" />
<el-table-column label="产品类型" prop="productType" width="200" />
<el-table-column label="商品ID" prop="productId" width="200" />
<el-table-column label="总评论数" prop="totalCount" width="120" align="center" />
<el-table-column label="可用数" prop="availableCount" width="120" align="center">
<template slot-scope="scope">
<span style="color: #67C23A; font-weight: bold;">{{ scope.row.availableCount }}</span>
</template>
</el-table-column>
<el-table-column label="已使用" prop="usedCount" width="120" align="center">
<template slot-scope="scope">
<span style="color: #909399;">{{ scope.row.usedCount }}</span>
</template>
</el-table-column>
<el-table-column label="接口调用次数" prop="apiCallCount" width="150" align="center">
<template slot-scope="scope">
<span style="color: #409EFF; font-weight: bold;">{{ scope.row.apiCallCount || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="今日调用" prop="todayCallCount" width="150" align="center">
<template slot-scope="scope">
<span style="color: #E6A23C; font-weight: bold;">{{ scope.row.todayCallCount || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="使用率" width="150" align="center">
<template slot-scope="scope">
<el-progress
:percentage="getUsagePercentage(scope.row)"
:color="getUsageColor(scope.row)"
:stroke-width="20"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template slot-scope="scope">
<el-button
v-if="scope.row.source === '京东评论'"
type="warning"
size="mini"
icon="el-icon-upload2"
:loading="statFetchLoading"
@click="handleStatFetchComments(scope.row)"
>获取评论</el-button>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<!-- 图片查看对话框 -->
<el-dialog title="评论图片" :visible.sync="imageDialogVisible" width="80%">
<div class="image-gallery">
<el-image
v-for="(img, index) in imageList"
:key="index"
:src="img"
:preview-src-list="imageList"
fit="contain"
style="width: 200px; height: 200px; margin: 10px;"
/>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listJdComment, getJdComment, updateJdComment, delJdComment, resetJdCommentByProductId,
listTbComment, getTbComment, updateTbComment, delTbComment, resetTbCommentByProductId,
getCommentStatistics, getJdProductTypeMap, getTbProductTypeMap, fetchComments
} from '@/api/jarvis/comment'
import { mapGetters } from 'vuex'
import MobileSearchForm from '@/components/MobileSearchForm'
import MobileButtonGroup from '@/components/MobileButtonGroup'
export default {
name: 'CommentManagement',
components: {
MobileSearchForm,
MobileButtonGroup
},
data() {
return {
activeTab: 'jd',
// 京东评论
jdLoading: false,
jdList: [],
jdTotal: 0,
jdQueryParams: {
pageNum: 1,
pageSize: 10,
productId: null,
productType: null,
userName: null,
isUse: null
},
jdDateRange: [],
jdProductTypeMap: {},
// 淘宝评论
tbLoading: false,
tbList: [],
tbTotal: 0,
tbQueryParams: {
pageNum: 1,
pageSize: 10,
productId: null,
productType: null,
userName: null,
isUse: null
},
tbDateRange: [],
tbProductTypeMap: {},
// 统计信息
statLoading: false,
statisticsList: [],
statQueryParams: {
source: null
},
// 图片查看
imageDialogVisible: false,
imageList: [],
// 获取评论 loading
jdFetchLoading: false,
tbFetchLoading: false,
statFetchLoading: false
}
},
computed: {
...mapGetters(['device']),
isMobile() {
if (this.device === 'mobile') {
return true
}
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return true
}
return false
},
jdActionButtons() {
return [
{ key: 'fetchComments', label: '获取评论', type: 'warning', icon: 'el-icon-upload2', handler: () => this.handleJdFetchComments(), disabled: this.jdFetchLoading },
{ key: 'export', label: '导出', type: 'success', icon: 'el-icon-download', handler: () => this.handleJdExport(), disabled: false }
]
},
tbActionButtons() {
return [
{ key: 'fetchComments', label: '获取评论', type: 'warning', icon: 'el-icon-upload2', handler: () => this.handleTbFetchComments(), disabled: this.tbFetchLoading },
{ key: 'export', label: '导出', type: 'success', icon: 'el-icon-download', handler: () => this.handleTbExport(), disabled: false }
]
}
},
created() {
this.getJdList()
this.getJdProductTypeMap()
this.getTbProductTypeMap()
},
methods: {
// 京东评论相关
getJdList() {
this.jdLoading = true
listJdComment(this.addDateRange(this.jdQueryParams, this.jdDateRange)).then(response => {
this.jdList = response.rows
this.jdTotal = response.total
this.jdLoading = false
})
},
handleJdQuery() {
this.jdQueryParams.pageNum = 1
this.getJdList()
},
resetJdQuery() {
this.jdDateRange = []
this.resetForm('jdQueryParams')
this.handleJdQuery()
},
handleJdDateRangeChange(value) {
this.jdDateRange = value
},
toggleJdCommentUse(row) {
const newIsUse = row.isUse === 0 ? 1 : 0
updateJdComment({ id: row.id, isUse: newIsUse }).then(() => {
this.$modal.msgSuccess('操作成功')
this.getJdList()
})
},
handleJdDelete(row) {
this.$modal.confirm('是否确认删除ID为"' + row.id + '"的评论?').then(() => {
return delJdComment(row.id)
}).then(() => {
this.getJdList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleJdExport() {
this.download('jarvis/comment/jd/export', {
...this.jdQueryParams
}, `jd_comment_${new Date().getTime()}.xlsx`)
},
/** 统计信息表格仅京东商品可点击用该行商品ID 调用获取评论 */
handleStatFetchComments(row) {
if (row.source !== '京东评论') return
const productId = (row.productId || '').toString().trim()
if (!productId) {
this.$modal.msgWarning('商品ID为空')
return
}
this.statFetchLoading = true
fetchComments(productId).then(() => {
this.$modal.msgSuccess('获取评论请求已发送')
this.getStatistics()
}).catch(e => {
this.$modal.msgError(e && (e.message || e.msg) || '获取评论失败')
}).finally(() => {
this.statFetchLoading = false
})
},
/** 京东调用获取评论接口使用商品ID(SKU) 作为 product_id */
handleJdFetchComments() {
const productId = (this.jdQueryParams.productId || '').toString().trim()
if (!productId) {
this.$modal.msgWarning('请先输入商品ID(SKU)')
return
}
this.jdFetchLoading = true
fetchComments(productId).then(() => {
this.$modal.msgSuccess('获取评论请求已发送')
this.getJdList()
}).catch(e => {
this.$modal.msgError(e && (e.message || e.msg) || '获取评论失败')
}).finally(() => {
this.jdFetchLoading = false
})
},
// 淘宝评论相关
getTbList() {
this.tbLoading = true
listTbComment(this.addDateRange(this.tbQueryParams, this.tbDateRange)).then(response => {
this.tbList = response.rows
this.tbTotal = response.total
this.tbLoading = false
})
},
handleTbQuery() {
this.tbQueryParams.pageNum = 1
this.getTbList()
},
resetTbQuery() {
this.tbDateRange = []
this.resetForm('tbQueryParams')
this.handleTbQuery()
},
handleTbDateRangeChange(value) {
this.tbDateRange = value
},
toggleTbCommentUse(row) {
const newIsUse = row.isUse === 0 ? 1 : 0
updateTbComment({ id: row.id, isUse: newIsUse }).then(() => {
this.$modal.msgSuccess('操作成功')
this.getTbList()
})
},
handleTbDelete(row) {
this.$modal.confirm('是否确认删除ID为"' + row.id + '"的评论?').then(() => {
return delTbComment(row.id)
}).then(() => {
this.getTbList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleTbExport() {
this.download('jarvis/taobaoComment/export', {
...this.tbQueryParams
}, `tb_comment_${new Date().getTime()}.xlsx`)
},
/** 淘宝调用获取评论接口使用商品ID(SKU) 作为 product_id */
handleTbFetchComments() {
const productId = (this.tbQueryParams.productId || '').toString().trim()
if (!productId) {
this.$modal.msgWarning('请先输入商品ID(SKU)')
return
}
this.tbFetchLoading = true
fetchComments(productId).then(() => {
this.$modal.msgSuccess('获取评论请求已发送')
this.getTbList()
}).catch(e => {
this.$modal.msgError(e && (e.message || e.msg) || '获取评论失败')
}).finally(() => {
this.tbFetchLoading = false
})
},
// 统计信息相关
getStatistics() {
this.statLoading = true
getCommentStatistics(this.statQueryParams.source).then(response => {
this.statisticsList = response.data
this.statLoading = false
})
},
resetStatistics() {
this.statQueryParams.source = null
this.getStatistics()
},
getUsagePercentage(row) {
if (!row.totalCount || row.totalCount === 0) return 0
return Math.round((row.usedCount / row.totalCount) * 100)
},
getUsageColor(row) {
const percentage = this.getUsagePercentage(row)
if (percentage < 50) return '#67C23A'
if (percentage < 80) return '#E6A23C'
return '#F56C6C'
},
// Redis映射
getJdProductTypeMap() {
getJdProductTypeMap().then(response => {
this.jdProductTypeMap = response.data || {}
})
},
getTbProductTypeMap() {
getTbProductTypeMap().then(response => {
this.tbProductTypeMap = response.data || {}
})
},
// 标签页切换
handleTabClick(tab) {
if (tab.name === 'jd') {
if (this.jdList.length === 0) {
this.getJdList()
}
} else if (tab.name === 'tb') {
if (this.tbList.length === 0) {
this.getTbList()
}
} else if (tab.name === 'statistics') {
if (this.statisticsList.length === 0) {
this.getStatistics()
}
}
},
// 查看图片
viewImages(pictureUrls) {
if (pictureUrls) {
try {
this.imageList = JSON.parse(pictureUrls)
} catch (e) {
this.imageList = pictureUrls.split(',').filter(url => url.trim())
}
this.imageDialogVisible = true
}
}
}
}
</script>
<style scoped>
.comment-container, .statistics-container {
padding: 20px;
}
.image-gallery {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
/* 操作按钮区域 */
.action-buttons-section {
margin-top: 12px;
margin-bottom: 12px;
}
/* 移动端和桌面端按钮组显示控制 */
@media (max-width: 768px) {
.desktop-only {
display: none !important;
}
.action-buttons-section.mobile-only {
display: block;
}
}
@media (min-width: 769px) {
.mobile-only {
display: none !important;
}
.desktop-action-buttons.desktop-only {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
}
.desktop-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,731 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="100px">
<el-form-item label="ERP账号" prop="appid" required>
<el-select
v-model="queryParams.appid"
placeholder="请选择ERP账号必选"
clearable
style="width: 200px"
@change="handleAccountChange"
>
<el-option
v-for="item in erpAccountList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span style="color: #f56c6c; margin-left: 10px; font-size: 12px;">* 不同账号的商品列表和权限不同</span>
</el-form-item>
<el-form-item label="商品标题" prop="title">
<el-input
v-model="queryParams.title"
placeholder="请输入商品标题"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="商品状态" prop="productStatus">
<el-select v-model="queryParams.productStatus" placeholder="请选择" clearable style="width: 150px">
<el-option label="全部" :value="null" />
<el-option label="删除" :value="-1" />
<el-option label="待发布" :value="21" />
<el-option label="销售中" :value="22" />
<el-option label="已售罄" :value="23" />
<el-option label="手动下架" :value="31" />
<el-option label="售出下架" :value="33" />
<el-option label="自动下架" :value="36" />
</el-select>
</el-form-item>
<el-form-item label="闲鱼会员名" prop="userName">
<el-input
v-model="queryParams.userName"
placeholder="请输入闲鱼会员名"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-refresh"
size="mini"
@click="handleSyncAll"
v-hasPermi="['jarvis:erpProduct:pull']"
>全量同步</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-top"
size="mini"
:disabled="multiple"
@click="handleBatchPublish"
v-hasPermi="['jarvis:erpProduct:publish']"
>批量上架</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-bottom"
size="mini"
:disabled="multiple"
@click="handleBatchDownShelf"
v-hasPermi="['jarvis:erpProduct:downShelf']"
>批量下架</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['jarvis:erpProduct:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['jarvis:erpProduct:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="erpProductList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="商品图片" align="center" prop="mainImage" width="100">
<template slot-scope="scope">
<el-image
v-if="scope.row.mainImage"
:src="scope.row.mainImage"
:preview-src-list="[scope.row.mainImage]"
style="width: 60px; height: 60px; border-radius: 4px;"
fit="cover"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="商品信息" align="left" min-width="250" :show-overflow-tooltip="true">
<template slot-scope="scope">
<div>
<div style="font-weight: bold; margin-bottom: 5px;">
{{ scope.row.title }}
</div>
<div style="color: #666; font-size: 12px;">
商品ID: {{ scope.row.productId }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="价格/库存" align="center" width="120">
<template slot-scope="scope">
<div>
<div style="color: #f56c6c; font-weight: bold;">
¥{{ formatPrice(scope.row.price) }}
</div>
<div style="color: #666; font-size: 12px;">
库存: {{ scope.row.stock || 0 }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="商品状态" align="center" prop="productStatus" width="100">
<template slot-scope="scope">
<el-tag
:type="getStatusType(scope.row.productStatus)"
size="mini"
>
{{ getStatusText(scope.row.productStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="闲鱼会员名" align="center" prop="userName" width="120" />
<el-table-column label="上架时间" align="center" width="180">
<template slot-scope="scope">
<div v-if="scope.row.onlineTime">
{{ formatTime(scope.row.onlineTime) }}
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="更新时间" align="center" width="180">
<template slot-scope="scope">
<div v-if="scope.row.updateTimeXy">
{{ formatTime(scope.row.updateTimeXy) }}
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="ERP应用" align="center" prop="appid" width="120" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleView(scope.row)"
>查看</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-top"
@click="handleSinglePublish(scope.row)"
v-if="scope.row.productStatus === 2"
v-hasPermi="['jarvis:erpProduct:publish']"
>上架</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-bottom"
@click="handleSingleDownShelf(scope.row)"
v-if="scope.row.productStatus === 1"
v-hasPermi="['jarvis:erpProduct:downShelf']"
>下架</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 全量同步对话框 -->
<el-dialog title="全量同步闲鱼商品" :visible.sync="syncDialogVisible" width="500px" append-to-body>
<el-form ref="syncForm" :model="syncForm" label-width="100px">
<el-form-item label="ERP应用">
<el-select v-model="syncForm.appid" placeholder="请选择ERP应用" style="width: 100%">
<el-option
v-for="item in erpAccountList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="商品状态">
<el-select v-model="syncForm.productStatus" placeholder="请选择(留空为全部)" clearable style="width: 100%">
<el-option label="全部" :value="null" />
<el-option label="删除" :value="-1" />
<el-option label="待发布" :value="21" />
<el-option label="销售中" :value="22" />
<el-option label="已售罄" :value="23" />
<el-option label="手动下架" :value="31" />
<el-option label="售出下架" :value="33" />
<el-option label="自动下架" :value="36" />
</el-select>
<div style="color: #909399; font-size: 12px; margin-top: 5px;">
<div> 留空表示同步全部状态的商品</div>
<div> 系统将自动遍历所有页码同步所有商品</div>
<div> 会自动更新本地已有商品删除远程已不存在的商品</div>
</div>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitSyncAll" :loading="syncing">开始同步</el-button>
<el-button @click="syncDialogVisible = false"> </el-button>
</div>
</el-dialog>
<!-- 查看商品详情对话框 -->
<el-dialog title="商品详情" :visible.sync="viewDialogVisible" width="800px" append-to-body>
<el-descriptions :column="2" border v-if="viewForm">
<el-descriptions-item label="商品ID">{{ viewForm.productId }}</el-descriptions-item>
<el-descriptions-item label="商品标题">{{ viewForm.title }}</el-descriptions-item>
<el-descriptions-item label="商品价格">
¥{{ formatPrice(viewForm.price) }}
</el-descriptions-item>
<el-descriptions-item label="商品库存">{{ viewForm.stock || 0 }}</el-descriptions-item>
<el-descriptions-item label="商品状态">
<el-tag :type="getStatusType(viewForm.productStatus)" size="mini">
{{ getStatusText(viewForm.productStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="销售状态">{{ viewForm.saleStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="闲鱼会员名">{{ viewForm.userName || '-' }}</el-descriptions-item>
<el-descriptions-item label="ERP应用">{{ viewForm.appid || '-' }}</el-descriptions-item>
<el-descriptions-item label="上架时间">
{{ viewForm.onlineTime ? formatTime(viewForm.onlineTime) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="下架时间">
{{ viewForm.offlineTime ? formatTime(viewForm.offlineTime) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="售出时间">
{{ viewForm.soldTime ? formatTime(viewForm.soldTime) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="商品链接" :span="2">
<el-link v-if="viewForm.productUrl" :href="viewForm.productUrl" target="_blank" type="primary">
{{ viewForm.productUrl }}
</el-link>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="商品图片" :span="2">
<el-image
v-if="viewForm.mainImage"
:src="viewForm.mainImage"
style="width: 200px; height: 200px;"
fit="cover"
/>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ viewForm.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<div slot="footer" class="dialog-footer">
<el-button @click="viewDialogVisible = false"> </el-button>
</div>
</el-dialog>
<!-- 批量上架对话框 -->
<el-dialog title="批量上架商品" :visible.sync="publishDialogVisible" width="500px" append-to-body>
<el-form ref="publishForm" :model="publishForm" label-width="120px">
<el-form-item label="选择账号">
<el-select v-model="publishForm.appid" placeholder="请选择ERP应用" style="width: 100%">
<el-option
v-for="item in erpAccountList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="闲鱼会员名" required>
<el-select
v-model="publishForm.userName"
placeholder="请选择闲鱼会员名"
filterable
style="width: 100%"
@focus="loadUsernames"
>
<el-option
v-for="item in usernameList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="商品数量">
<el-input :value="selectedProductIds.length" readonly />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitBatchPublish" :loading="publishing"> </el-button>
<el-button @click="publishDialogVisible = false"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listErpProduct, getErpProduct, delErpProduct, pullProductList, syncAllProducts, batchPublish, batchDownShelf, getERPAccounts, getUsernames } from "@/api/system/erpProduct";
export default {
name: "ErpProduct",
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 选中的商品ID数组
selectedProductIds: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 闲鱼商品表格数据
erpProductList: [],
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
title: null,
productStatus: null,
userName: null,
appid: null
},
// ERP账号列表
erpAccountList: [],
// 全量同步对话框
syncDialogVisible: false,
syncForm: {
appid: null,
productStatus: null
},
syncing: false,
// 查看对话框
viewDialogVisible: false,
viewForm: null,
// 账号切换提示
accountWarningShown: false,
// 批量上架对话框
publishDialogVisible: false,
publishForm: {
appid: null,
userName: null
},
publishing: false,
// 会员名列表
usernameList: []
};
},
created() {
this.loadERPAccounts();
},
methods: {
/** 查询闲鱼商品列表 */
getList() {
// 如果没有选择账号,提示用户
if (!this.queryParams.appid) {
this.$modal.msgWarning("请先选择ERP账号");
this.loading = false;
return;
}
this.loading = true;
listErpProduct(this.queryParams).then(response => {
this.erpProductList = response.rows;
this.total = response.total;
this.loading = false;
}).catch(() => {
this.loading = false;
});
},
/** 加载ERP账号列表 */
loadERPAccounts() {
getERPAccounts().then(response => {
this.erpAccountList = response.data || [];
// 自动将第一个ERP账号作为参数并调用列表数据
if (this.erpAccountList.length > 0 && !this.queryParams.appid) {
this.queryParams.appid = this.erpAccountList[0].value;
this.getList();
} else if (!this.queryParams.appid) {
this.loading = false;
}
}).catch(() => {
this.loading = false;
});
},
/** 加载会员名列表 */
loadUsernames() {
getUsernames({ pageSize: 100 }).then(response => {
this.usernameList = response.data || [];
});
},
/** 账号变更处理 */
handleAccountChange() {
// 账号切换时清空选中项
this.ids = [];
this.selectedProductIds = [];
this.multiple = true;
this.single = true;
// 重新加载列表
this.queryParams.pageNum = 1;
this.getList();
},
/** 搜索按钮操作 */
handleQuery() {
if (!this.queryParams.appid) {
this.$modal.msgWarning("请先选择ERP账号");
return;
}
this.queryParams.pageNum = 1;
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id);
this.selectedProductIds = selection.map(item => item.productId);
this.single = selection.length !== 1;
this.multiple = !selection.length;
},
/** 全量同步按钮操作 */
handleSyncAll() {
if (!this.queryParams.appid) {
this.$modal.msgWarning("请先选择ERP账号");
return;
}
this.syncDialogVisible = true;
this.syncForm = {
appid: this.queryParams.appid,
productStatus: null
};
},
/** 提交全量同步 */
submitSyncAll() {
if (!this.syncForm.appid) {
this.$modal.msgWarning("请选择ERP账号");
return;
}
this.$confirm(
'全量同步将自动遍历所有页码,同步所有商品数据,并删除远程已不存在的本地商品。是否继续?',
'确认全量同步',
{
confirmButtonText: '确定同步',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
this.syncing = true;
syncAllProducts(this.syncForm).then(response => {
if (response.code === 200) {
this.$modal.msgSuccess(response.msg || "同步成功");
this.syncDialogVisible = false;
// 刷新列表
this.getList();
} else {
this.$modal.msgError(response.msg || "同步失败");
}
this.syncing = false;
}).catch((error) => {
this.$modal.msgError(error.message || "同步失败");
this.syncing = false;
});
}).catch(() => {
// 用户取消
});
},
/** 查看按钮操作 */
handleView(row) {
const id = row.id || this.ids[0];
getErpProduct(id).then(response => {
this.viewForm = response.data;
this.viewDialogVisible = true;
});
},
/** 单个上架 */
handleSinglePublish(row) {
if (!row.appid) {
this.$modal.msgWarning("该商品缺少ERP账号信息无法上架");
return;
}
this.$prompt('请输入闲鱼会员名', '上架商品', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '请输入闲鱼会员名',
inputValidator: (value) => {
if (!value) {
return '闲鱼会员名不能为空';
}
return true;
}
}).then(({ value }) => {
const data = {
productIds: [row.productId],
userName: value,
appid: row.appid
};
this.publishing = true;
batchPublish(data).then(response => {
this.$modal.msgSuccess("上架成功");
this.publishing = false;
this.getList();
}).catch(() => {
this.publishing = false;
});
}).catch(() => {});
},
/** 单个下架 */
handleSingleDownShelf(row) {
if (!row.appid) {
this.$modal.msgWarning("该商品缺少ERP账号信息无法下架");
return;
}
this.$confirm('确定要下架该商品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const data = {
productIds: [row.productId],
appid: row.appid
};
batchDownShelf(data).then(response => {
this.$modal.msgSuccess("下架成功");
this.getList();
});
}).catch(() => {});
},
/** 批量上架按钮操作 */
handleBatchPublish() {
if (this.selectedProductIds.length === 0) {
this.$modal.msgWarning("请先选择要上架的商品");
return;
}
// 检查选中的商品是否属于同一个账号
const selectedProducts = this.erpProductList.filter(item => this.selectedProductIds.includes(item.productId));
const appids = [...new Set(selectedProducts.map(p => p.appid))];
if (appids.length > 1) {
this.$modal.msgWarning("选中的商品属于不同的ERP账号请分别操作");
return;
}
const accountAppid = appids[0] || this.queryParams.appid;
if (!accountAppid) {
this.$modal.msgWarning("请先选择ERP账号或确保选中的商品有关联的账号");
return;
}
this.publishForm = {
appid: accountAppid,
userName: null
};
this.publishDialogVisible = true;
},
/** 提交批量上架 */
submitBatchPublish() {
if (!this.publishForm.userName) {
this.$modal.msgWarning("请选择闲鱼会员名");
return;
}
const data = {
productIds: this.selectedProductIds,
userName: this.publishForm.userName,
appid: this.publishForm.appid
};
this.publishing = true;
batchPublish(data).then(response => {
this.$modal.msgSuccess(response.msg || "批量上架成功");
this.publishDialogVisible = false;
this.publishing = false;
this.getList();
}).catch(() => {
this.publishing = false;
});
},
/** 批量下架按钮操作 */
handleBatchDownShelf() {
if (this.selectedProductIds.length === 0) {
this.$modal.msgWarning("请先选择要下架的商品");
return;
}
// 检查选中的商品是否属于同一个账号
const selectedProducts = this.erpProductList.filter(item => this.selectedProductIds.includes(item.productId));
const appids = [...new Set(selectedProducts.map(p => p.appid))];
if (appids.length > 1) {
this.$modal.msgWarning("选中的商品属于不同的ERP账号请分别操作");
return;
}
const accountAppid = appids[0] || this.queryParams.appid;
if (!accountAppid) {
this.$modal.msgWarning("请先选择ERP账号或确保选中的商品有关联的账号");
return;
}
this.$confirm('确定要批量下架选中的 ' + this.selectedProductIds.length + ' 个商品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const data = {
productIds: this.selectedProductIds,
appid: accountAppid
};
batchDownShelf(data).then(response => {
this.$modal.msgSuccess(response.msg || "批量下架成功");
this.getList();
});
}).catch(() => {});
},
/** 删除按钮操作 */
handleDelete(row) {
const ids = row.id || this.ids;
this.$modal.confirm('是否确认删除闲鱼商品编号为"' + ids + '"的数据项?').then(() => {
return delErpProduct(ids);
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
},
/** 导出按钮操作(与当前筛选条件一致,导出全部匹配行,非当前分页) */
handleExport() {
if (!this.queryParams.appid) {
this.$modal.msgWarning("请先选择ERP账号");
return;
}
const { pageNum, pageSize, ...exportQuery } = this.queryParams;
this.download(
'jarvis/erpProduct/export',
exportQuery,
`闲鱼商品_AI明细_${new Date().getTime()}.xlsx`
);
},
/** 格式化价格(分转元) */
formatPrice(price) {
if (price == null) return '0.00';
return (price / 100).toFixed(2);
},
/** 格式化时间(时间戳转日期) */
formatTime(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp * 1000);
return this.parseTime(date, '{y}-{m}-{d} {h}:{i}:{s}');
},
/** 获取状态文本 */
getStatusText(status) {
if (status == null) return '-';
const statusMap = {
'-1': '删除',
'21': '待发布',
'22': '销售中',
'23': '已售罄',
'31': '手动下架',
'33': '售出下架',
'36': '自动下架'
};
return statusMap[String(status)] || '未知(' + status + ')';
},
/** 获取状态类型 */
getStatusType(status) {
if (status == null) return '';
const typeMap = {
'-1': 'danger', // 删除
'21': 'info', // 待发布
'22': 'success', // 销售中
'23': 'warning', // 已售罄
'31': 'warning', // 手动下架
'33': 'info', // 售出下架
'36': 'warning' // 自动下架
};
return typeMap[String(status)] || '';
}
}
};
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,707 @@
<template>
<div class="app-container">
<mobile-search-form
:model="queryParams"
@search="handleQuery"
@reset="resetQuery"
>
<template #form="{ expanded }">
<el-form
:model="queryParams"
ref="queryForm"
size="small"
:inline="true"
v-show="showSearch"
label-width="68px"
>
<el-form-item label="商品名称" prop="productName">
<el-input
v-model="queryParams.productName"
placeholder="请输入商品名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="店铺名称" prop="shopName">
<el-input
v-model="queryParams.shopName"
placeholder="请输入店铺名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="SKUID" prop="skuid">
<el-input
v-model="queryParams.skuid"
placeholder="请输入SKUID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="是否置顶" prop="isTop">
<el-select v-model="queryParams.isTop" placeholder="请选择" clearable>
<el-option label="是" :value="1" />
<el-option label="否" :value="0" />
</el-select>
</el-form-item>
<!-- 桌面端搜索按钮 -->
<el-form-item v-if="!expanded">
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</template>
</mobile-search-form>
<!-- 操作按钮区域移动端单独显示 -->
<div class="action-buttons-section mobile-only">
<mobile-button-group
:buttons="actionButtons"
:primary-count="2"
/>
</div>
<!-- 桌面端按钮组 -->
<el-row :gutter="10" class="mb8 desktop-only">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['jarvis:favoriteProduct:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['jarvis:favoriteProduct:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['jarvis:favoriteProduct:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-top"
size="mini"
:disabled="multiple"
@click="handleBatchTop"
v-hasPermi="['jarvis:favoriteProduct:edit']"
>批量置顶</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['jarvis:favoriteProduct:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="favoriteProductList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="商品图片" align="center" prop="productImage" width="100">
<template slot-scope="scope">
<el-image
v-if="scope.row.productImage"
:src="scope.row.productImage"
:preview-src-list="[scope.row.productImage]"
style="width: 60px; height: 60px; border-radius: 4px;"
fit="cover"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="商品名称" align="left" prop="productName" min-width="200" :show-overflow-tooltip="true">
<template slot-scope="scope">
<div>
<div style="font-weight: bold; margin-bottom: 5px;">
<el-tag v-if="scope.row.isTop === 1" type="danger" size="mini">置顶</el-tag>
{{ scope.row.productName }}
</div>
<div style="color: #666; font-size: 12px;">
SKUID: {{ scope.row.skuid }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="店铺信息" align="left" prop="shopName" min-width="150">
<template slot-scope="scope">
<div>
<div>{{ scope.row.shopName }}</div>
<div style="color: #666; font-size: 12px;">ID: {{ scope.row.shopId }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="价格信息" align="center" prop="price" width="120">
<template slot-scope="scope">
<div>
<div style="color: #f56c6c; font-weight: bold;">¥{{ scope.row.price || '-' }}</div>
<div v-if="scope.row.commissionInfo" style="color: #67c23a; font-size: 12px;">
佣金: {{ scope.row.commissionInfo }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="ERP商品" align="center" prop="erpProductIds" width="120">
<template slot-scope="scope">
<el-button
v-if="scope.row.erpProductIds"
type="text"
size="mini"
@click="showErpProducts(scope.row)"
>
查看({{ JSON.parse(scope.row.erpProductIds || '[]').length }})
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="使用统计" align="center" width="120">
<template slot-scope="scope">
<div>
<div>使用: {{ scope.row.useCount || 0 }}</div>
<div style="color: #666; font-size: 12px;">
{{ scope.row.lastUsedTime || '未使用' }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="创建信息" align="center" width="150">
<template slot-scope="scope">
<div>
<div>{{ scope.row.createUserName }}</div>
<div style="color: #666; font-size: 12px;">
{{ parseTime(scope.row.createTime) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleView(scope.row)"
>查看</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['jarvis:favoriteProduct:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-top"
@click="handleTop(scope.row)"
v-hasPermi="['jarvis:favoriteProduct:edit']"
>
{{ scope.row.isTop === 1 ? '取消置顶' : '置顶' }}
</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-shopping-cart-2"
@click="handleQuickPublish(scope.row)"
>快速发品</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['jarvis:favoriteProduct:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改常用商品对话框 -->
<el-dialog :title="title" :visible.sync="open" width="800px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-row>
<el-col :span="12">
<el-form-item label="SKUID" prop="skuid">
<el-input v-model="form.skuid" placeholder="请输入SKUID" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品名称" prop="productName">
<el-input v-model="form.productName" placeholder="请输入商品名称" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="店铺名称" prop="shopName">
<el-input v-model="form.shopName" placeholder="请输入店铺名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="店铺ID" prop="shopId">
<el-input v-model="form.shopId" placeholder="请输入店铺ID" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="商品链接" prop="productUrl">
<el-input v-model="form.productUrl" placeholder="请输入商品链接" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品图片" prop="productImage">
<el-input v-model="form.productImage" placeholder="请输入商品图片URL" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="商品价格" prop="price">
<el-input v-model="form.price" placeholder="请输入商品价格" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="佣金信息" prop="commissionInfo">
<el-input v-model="form.commissionInfo" placeholder="请输入佣金信息" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="商品分类" prop="category">
<el-input v-model="form.category" placeholder="请输入商品分类" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品品牌" prop="brand">
<el-input v-model="form.brand" placeholder="请输入商品品牌" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="是否置顶" prop="isTop">
<el-radio-group v-model="form.isTop">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序权重" prop="sortWeight">
<el-input-number v-model="form.sortWeight" :min="0" :max="999" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
<!-- ERP商品详情对话框 -->
<el-dialog title="ERP商品详情" :visible.sync="erpDialogVisible" width="800px" append-to-body>
<el-table :data="erpProducts" border style="width: 100%">
<el-table-column label="ERP应用ID" prop="appid" width="120" />
<el-table-column label="ERP商品ID" prop="erpProductId" width="120" />
<el-table-column label="商品标题" prop="erpProductTitle" min-width="200" :show-overflow-tooltip="true" />
<el-table-column label="商品状态" prop="erpProductStatus" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.erpProductStatus === 'active' ? 'success' : 'info'">
{{ scope.row.erpProductStatus === 'active' ? '正常' : '其他' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="商品链接" prop="erpProductUrl" min-width="200">
<template slot-scope="scope">
<el-link :href="scope.row.erpProductUrl" target="_blank" type="primary">
查看商品
</el-link>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" width="120" />
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="erpDialogVisible = false"> </el-button>
</div>
</el-dialog>
<!-- 通用发品对话框从常用商品直接进入不显示ERP应用选择 -->
<PublishDialog :visible.sync="publishDialogVisible" :initial-data="publishInitialData" :hideAppid="true" />
</div>
</template>
<script>
import { listFavoriteProduct, getFavoriteProduct, delFavoriteProduct, addFavoriteProduct, updateFavoriteProduct, updateTopStatus } from "@/api/system/favoriteProduct";
import { generatePromotionContent } from "@/api/system/jdorder";
import { mapGetters, mapActions } from 'vuex'
import MobileSearchForm from '@/components/MobileSearchForm'
import MobileButtonGroup from '@/components/MobileButtonGroup'
import PublishDialog from '@/components/PublishDialog.vue'
// 自动加入常用逻辑由 PublishDialog 内部触发(线报、转链页面),本页主要用于打开发品弹窗
export default {
name: "FavoriteProduct",
components: {
PublishDialog,
MobileSearchForm,
MobileButtonGroup
},
computed: {
...mapGetters(['favoriteProductRefreshKey', 'device']),
isMobile() {
if (this.device === 'mobile') {
return true
}
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return true
}
return false
},
actionButtons() {
return [
{ key: 'add', label: '新增', type: 'primary', icon: 'el-icon-plus', handler: () => this.handleAdd(), disabled: false },
{ key: 'update', label: '修改', type: 'success', icon: 'el-icon-edit', handler: () => this.handleUpdate(), disabled: this.single },
{ key: 'delete', label: '删除', type: 'danger', icon: 'el-icon-delete', handler: () => this.handleDelete(), disabled: this.multiple },
{ key: 'top', label: '批量置顶', type: 'warning', icon: 'el-icon-top', handler: () => this.handleBatchTop(), disabled: this.multiple },
{ key: 'export', label: '导出', type: 'info', icon: 'el-icon-download', handler: () => this.handleExport(), disabled: false }
]
}
},
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 常用商品表格数据
favoriteProductList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
productName: null,
shopName: null,
skuid: null,
isTop: null
},
// 表单参数
form: {},
// 表单校验
rules: {
skuid: [
{ required: true, message: "SKUID不能为空", trigger: "blur" }
],
productName: [
{ required: true, message: "商品名称不能为空", trigger: "blur" }
],
shopName: [
{ required: true, message: "店铺名称不能为空", trigger: "blur" }
]
},
// ERP商品对话框
erpDialogVisible: false,
erpProducts: [],
// 通用发品弹窗
publishDialogVisible: false,
publishInitialData: {}
};
},
created() {
this.getList();
},
watch: {
favoriteProductRefreshKey() {
// 全局刷新标记变更时,自动刷新列表
this.getList();
}
},
methods: {
/** 查询常用商品列表 */
getList() {
this.loading = true;
listFavoriteProduct(this.queryParams).then(response => {
this.favoriteProductList = response.rows;
this.total = response.total;
this.loading = false;
});
},
// 取消按钮
cancel() {
this.open = false;
this.reset();
},
// 表单重置
reset() {
this.form = {
id: null,
skuid: null,
productName: null,
shopName: null,
shopId: null,
productUrl: null,
productImage: null,
price: null,
commissionInfo: null,
isTop: 0,
sortWeight: 0,
remark: null,
category: null,
brand: null
};
this.resetForm("form");
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id)
this.single = selection.length!==1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.open = true;
this.title = "添加常用商品";
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
const id = row.id || this.ids
getFavoriteProduct(id).then(response => {
this.form = response.data;
this.open = true;
this.title = "修改常用商品";
});
},
/** 查看按钮操作 */
handleView(row) {
this.reset();
const id = row.id || this.ids
getFavoriteProduct(id).then(response => {
this.form = response.data;
this.open = true;
this.title = "查看常用商品";
// 设置为只读
this.$nextTick(() => {
Object.keys(this.form).forEach(key => {
const input = this.$refs.form.$el.querySelector(`[name="${key}"]`);
if (input) {
input.disabled = true;
}
});
});
});
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.id != null) {
updateFavoriteProduct(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
});
} else {
addFavoriteProduct(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
}
}
});
},
/** 删除按钮操作 */
handleDelete(row) {
const ids = row.id || this.ids;
this.$modal.confirm('是否确认删除常用商品编号为"' + ids + '"的数据项?').then(function() {
return delFavoriteProduct(ids);
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
},
/** 置顶按钮操作 */
handleTop(row) {
const isTop = row.isTop === 1 ? 0 : 1;
const action = isTop === 1 ? '置顶' : '取消置顶';
this.$modal.confirm('是否确认' + action + '商品"' + row.productName + '"').then(function() {
return updateTopStatus(row.id, isTop);
}).then(() => {
this.getList();
this.$modal.msgSuccess(action + "成功");
}).catch(() => {});
},
/** 批量置顶操作 */
handleBatchTop() {
this.$modal.confirm('是否确认批量置顶选中的商品?').then(function() {
// 这里需要调用批量置顶接口
return Promise.resolve();
}).then(() => {
this.getList();
this.$modal.msgSuccess("批量置顶成功");
}).catch(() => {});
},
/** 导出按钮操作 */
handleExport() {
this.download('jarvis/favoriteProduct/export', {
...this.queryParams
}, `favoriteProduct_${new Date().getTime()}.xlsx`)
},
/** 显示ERP商品详情 */
showErpProducts(row) {
try {
this.erpProducts = JSON.parse(row.erpProductIds || '[]');
this.erpDialogVisible = true;
} catch (e) {
this.$modal.msgError("ERP商品数据格式错误");
}
},
/** 快速发品 */
handleQuickPublish(row) {
const id = row.id || this.ids;
getFavoriteProduct(id).then(async res => {
const p = res && res.data ? res.data : (row || {});
try {
// 与转链保持一致:优先使用保存的商品链接,避免用名称/ID 产生歧义
if (!p.productUrl) {
this.$modal.msgWarning('该常用商品缺少商品链接,无法生成完整发品信息');
}
let detail = null;
if (p.productUrl) {
const r = await generatePromotionContent({ promotionContent: p.productUrl });
const resultStr = (r && (r.msg || r.data)) || '';
try { const arr = typeof resultStr === 'string' ? JSON.parse(resultStr) : resultStr; if (Array.isArray(arr) && arr.length) detail = arr[0]; } catch(e) {}
}
const images = Array.isArray(detail && detail.images) && detail.images.length ? detail.images : (p.productImage ? [p.productImage] : []);
const wenanArr = Array.isArray(detail && detail.wenan) ? detail.wenan : [];
const wenanOptions = wenanArr.map((w, i) => ({ label: w.type || `版本${i+1}` , content: w.content || '' }));
this.publishInitialData = {
title: (detail && (detail.skuName || detail.title)) || p.productName || '',
content: (wenanOptions[0] && wenanOptions[0].content) || '',
images: images,
originalPrice: detail && detail.price ? Number(detail.price) : (p.price ? Number(p.price) : undefined),
wenanOptions: wenanOptions,
userName: p.userName || '',
province: p.province || null,
city: p.city || null,
district: p.district || null
};
this.publishDialogVisible = true;
} catch (e) {
const imagesFallback = p.productImage ? [p.productImage] : [];
this.publishInitialData = { title: p.productName || '', images: imagesFallback, content: '' };
this.publishDialogVisible = true;
}
}).catch(() => {
const images = row && row.productImage ? [row.productImage] : [];
this.publishInitialData = { title: row.productName || '', images, content: '' };
this.publishDialogVisible = true;
});
},
}
};
</script>
<style scoped>
.mb8 {
margin-bottom: 8px;
}
.el-table .el-table__row:hover {
background-color: #f5f7fa;
}
.el-tag {
margin-right: 5px;
}
/* 操作按钮区域 */
.action-buttons-section {
margin-top: 12px;
margin-bottom: 12px;
}
/* 移动端和桌面端按钮组显示控制 */
@media (max-width: 768px) {
.desktop-only {
display: none !important;
}
.action-buttons-section.mobile-only {
display: block;
}
}
@media (min-width: 769px) {
.mobile-only {
display: none !important;
}
.desktop-only {
display: block;
}
}
</style>

View File

@@ -0,0 +1,460 @@
<template>
<div class="app-container">
<el-card shadow="never">
<div slot="header">
<span style="font-weight: bold; font-size: 18px;">批量创建礼金并替换URL</span>
<el-divider direction="vertical"></el-divider>
<span style="color: #909399; font-size: 14px;">一键操作粘贴文案 自动创建礼金 输出替换后的文案</span>
</div>
<!-- 配置区域 -->
<el-form :model="form" :rules="rules" ref="form" inline style="margin-bottom: 15px;">
<el-form-item label="礼金金额" prop="amount">
<el-input-number v-model="form.amount" :min="1" :max="50" :precision="2" :step="0.01" style="width: 120px" />
<span style="margin-left: 5px; color: #909399;"></span>
</el-form-item>
<el-form-item label="每张数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" :max="100" style="width: 120px" />
<span style="margin-left: 5px; color: #909399;"></span>
</el-form-item>
<el-form-item label="商品类型" prop="owner">
<el-radio-group v-model="form.owner">
<el-radio label="g">自营</el-radio>
<el-radio label="pop">POP</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item style="margin-left: 20px;">
<el-tag v-if="detectedUrls.length > 0" type="success" size="medium">
<i class="el-icon-link"></i> 已识别 {{ detectedUrls.length }} 个URL
</el-tag>
</el-form-item>
</el-form>
<!-- 左右两个文本框 -->
<el-row :gutter="20">
<!-- 左侧输入文案 -->
<el-col :span="12">
<div class="text-panel">
<div class="panel-header">
<span style="font-weight: bold; font-size: 16px;">
<i class="el-icon-edit-outline"></i> 输入原始文案
</span>
<el-button
type="primary"
size="small"
@click="handleProcess"
:loading="processing"
:disabled="detectedUrls.length === 0"
>
<i class="el-icon-magic-stick"></i> 一键生成 ({{ detectedUrls.length }})
</el-button>
</div>
<el-input
type="textarea"
:rows="25"
v-model="content"
placeholder="💡 将包含京东链接的完整推广文案粘贴到这里&#10;&#10;示例:&#10;🔴【海尔电热水器】11月9号晚8好价&#10;✅9折券商品页面直接领取&#10;&#10;1⃣海尔无镁棒BK5PLUS60升&#10;下单https://u.jd.com/T1G7978&#10;👉300券+388红包&#10;&#10;2⃣海尔无镁棒BK5PLUS80升&#10;下单https://u.jd.com/TrG7lCN&#10;👉300券+388红包&#10;&#10;✨ 系统会自动识别所有京东链接并替换!"
class="textarea-input"
/>
</div>
</el-col>
<!-- 右侧输出结果 -->
<el-col :span="12">
<div class="text-panel">
<div class="panel-header">
<span style="font-weight: bold; font-size: 16px;">
<i class="el-icon-document-checked"></i> 替换后的文案
</span>
<el-button
type="success"
size="small"
@click="copyResult"
:disabled="!result || !result.replacedContent"
>
<i class="el-icon-document-copy"></i> 复制结果
</el-button>
</div>
<el-input
v-if="!processing && result && result.replacedContent"
type="textarea"
:rows="25"
v-model="result.replacedContent"
readonly
class="textarea-output"
/>
<div v-else-if="processing" class="loading-container">
<el-progress
:percentage="progress"
:status="progressStatus"
:stroke-width="15"
style="width: 80%;"
>
<template slot="format">
{{ progressText }}
</template>
</el-progress>
<div style="margin-top: 15px; color: #909399; font-size: 14px;">
{{ progressDetail }}
</div>
</div>
<div v-else class="empty-container">
<i class="el-icon-document" style="font-size: 64px; color: #DCDFE6; margin-bottom: 10px;"></i>
<div style="color: #909399; font-size: 14px;">点击左侧"一键生成"按钮后替换结果将显示在这里</div>
</div>
</div>
<!-- 统计信息 -->
<div v-if="result && result.replacedContent" style="margin-top: 15px;">
<el-alert
:type="result.replacedCount === result.totalUrls ? 'success' : (result.replacedCount > 0 ? 'warning' : 'error')"
:closable="false"
>
<template slot="title">
<span style="font-weight: bold;">
<i :class="result.replacedCount === result.totalUrls ? 'el-icon-success' : (result.replacedCount > 0 ? 'el-icon-warning' : 'el-icon-error')"></i> 处理完成
成功替换 {{ result.replacedCount || 0 }} / {{ result.totalUrls || 0 }} 个URL
</span>
</template>
</el-alert>
</div>
</el-col>
</el-row>
<!-- 详细结果展示区域 -->
<el-row v-if="result && result.replacements && result.replacements.length > 0" style="margin-top: 20px;">
<el-col :span="24">
<el-card shadow="never">
<div slot="header">
<span style="font-weight: bold; font-size: 16px;">
<i class="el-icon-document"></i> 详细处理结果
</span>
<el-button
type="text"
size="small"
style="float: right;"
@click="showDetailResults = !showDetailResults"
>
{{ showDetailResults ? '收起' : '展开' }}
<i :class="showDetailResults ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i>
</el-button>
</div>
<div v-show="showDetailResults">
<el-table
:data="result.replacements"
stripe
border
style="width: 100%"
:default-sort="{prop: 'index', order: 'ascending'}"
>
<el-table-column prop="index" label="序号" width="80" align="center" sortable />
<el-table-column prop="skuName" label="商品名称" min-width="200" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.skuName">{{ scope.row.skuName }}</span>
<span v-else style="color: #909399;">-</span>
</template>
</el-table-column>
<el-table-column prop="originalUrl" label="原始链接" min-width="250" show-overflow-tooltip>
<template slot-scope="scope">
<el-link :href="scope.row.originalUrl" target="_blank" type="primary" :underline="false">
{{ scope.row.originalUrl }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="newUrl" label="新链接" min-width="250" show-overflow-tooltip>
<template slot-scope="scope">
<el-link
v-if="scope.row.success && scope.row.newUrl"
:href="scope.row.newUrl"
target="_blank"
type="success"
:underline="false"
>
{{ scope.row.newUrl }}
</el-link>
<span v-else style="color: #909399;">未替换</span>
</template>
</el-table-column>
<el-table-column prop="success" label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.success ? 'success' : 'danger'" size="small">
<i :class="scope.row.success ? 'el-icon-success' : 'el-icon-error'"></i>
{{ scope.row.success ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息" min-width="300" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.error" style="color: #F56C6C;">{{ scope.row.error }}</span>
<span v-else style="color: #67C23A;"> 处理成功</span>
</template>
</el-table-column>
<el-table-column prop="giftCouponKey" label="礼金券Key" min-width="150" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.giftCouponKey" style="color: #409EFF;">{{ scope.row.giftCouponKey }}</span>
<span v-else style="color: #909399;">-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script>
import { replaceUrlsWithGiftCoupons } from '@/api/system/jdorder'
export default {
name: 'BatchGiftCoupon',
data() {
return {
content: '',
form: {
amount: 1.8,
quantity: 12,
owner: 'g'
},
rules: {
amount: [{ required: true, message: '请输入礼金金额', trigger: 'blur' }],
quantity: [{ required: true, message: '请输入礼金数量', trigger: 'blur' }]
},
processing: false,
progress: 0,
progressText: '',
progressDetail: '',
progressStatus: '',
result: null,
detectedUrls: [],
showDetailResults: true
}
},
watch: {
content(newVal) {
this.detectUrls(newVal)
}
},
methods: {
/** 检测文本中的URL */
detectUrls(text) {
if (!text || text.trim().length === 0) {
this.detectedUrls = []
return
}
const urlPattern = /(https?:\/\/[^\s]+)|(u\.jd\.com\/[^\s]+)/gi
const urls = []
let match
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0]
if (url && !urls.includes(url.trim())) {
urls.push(url.trim())
}
}
this.detectedUrls = urls
},
/** 一键处理 */
async handleProcess() {
if (!this.$refs.form) {
return
}
this.$refs.form.validate(async (valid) => {
if (!valid) return
if (this.detectedUrls.length === 0) {
this.$modal.msgWarning('文本中未找到URL请输入包含京东商品链接的文案')
return
}
if (this.detectedUrls.length > 100) {
this.$modal.msgError('检测到的URL数量超过100个请分批处理')
return
}
// 确认操作
try {
await this.$confirm(
`检测到 ${this.detectedUrls.length} 个商品链接,将自动批量创建 ${this.detectedUrls.length}${this.form.amount} 元礼金券并替换文案中的URL。是否继续`,
'确认批量创建',
{
confirmButtonText: '确定创建',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return
}
this.processing = true
this.progress = 20
this.progressText = '正在处理...'
this.progressDetail = '后端正在为每个URL单独创建礼金券请耐心等待...'
this.progressStatus = ''
this.result = null
try {
// 调用后端接口后端会为每个URL单独处理
const params = {
content: this.content,
amount: this.form.amount,
quantity: this.form.quantity,
owner: this.form.owner || 'g'
}
this.progress = 40
const res = await replaceUrlsWithGiftCoupons(params)
this.progress = 80
if (res && res.code === 200 && res.data) {
this.result = res.data
const successCount = this.result.replacedCount || 0
const totalCount = this.result.totalUrls || 0
this.progress = 100
this.progressStatus = successCount === totalCount ? 'success' : (successCount > 0 ? 'warning' : 'exception')
this.progressText = successCount === totalCount ? '完成!' : '部分成功'
this.progressDetail = `成功替换 ${successCount} / ${totalCount} 个URL`
if (successCount > 0) {
this.$modal.msgSuccess(`✅ 批量替换完成!成功 ${successCount} / ${totalCount}`)
} else {
this.$modal.msgError('批量替换失败所有URL处理均失败')
}
} else {
this.progress = 100
this.progressStatus = 'exception'
this.progressText = '失败'
this.progressDetail = res.msg || '未知错误'
this.$modal.msgError('批量替换失败:' + (res.msg || '未知错误'))
}
} catch (e) {
console.error('批量替换异常', e)
this.progress = 100
this.progressStatus = 'exception'
this.progressText = '失败'
this.progressDetail = e.message || '未知错误'
let errorMsg = '未知错误'
if (e.response && e.response.data) {
errorMsg = e.response.data.msg || e.response.data.message || JSON.stringify(e.response.data)
} else if (e.message) {
errorMsg = e.message
}
this.$modal.msgError('操作失败:' + errorMsg)
} finally {
this.processing = false
}
})
},
/** 复制结果 */
copyResult() {
if (this.result && this.result.replacedContent) {
this.copyToClipboard(this.result.replacedContent)
}
},
/** 复制到剪贴板 */
copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
this.$modal.msgSuccess('✅ 复制成功!可以直接发送给用户了')
}).catch(() => {
this.fallbackCopyToClipboard(text)
})
} else {
this.fallbackCopyToClipboard(text)
}
},
/** 降级复制方法 */
fallbackCopyToClipboard(text) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
this.$modal.msgSuccess('✅ 复制成功!')
} catch (err) {
this.$modal.msgError('复制失败')
}
document.body.removeChild(textArea)
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
.text-panel {
border: 2px solid #DCDFE6;
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.textarea-input, .textarea-output {
border: none !important;
border-radius: 0 !important;
}
.textarea-input >>> textarea {
border: none !important;
border-radius: 0 !important;
font-size: 14px;
line-height: 1.8;
padding: 20px;
}
.textarea-output >>> textarea {
border: none !important;
border-radius: 0 !important;
font-size: 14px;
line-height: 1.8;
padding: 20px;
background: #f5f7fa;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 550px;
padding: 40px;
background: #f5f7fa;
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 550px;
background: #fafafa;
}
</style>

View File

@@ -0,0 +1,999 @@
<template>
<div>
<list-layout>
<!-- 搜索区域 -->
<template #search>
<mobile-search-form
:model="queryParams"
@search="handleQuery"
@reset="resetQuery"
>
<template #form="{ expanded }">
<el-form
:model="queryParams"
:inline="true"
label-width="100px"
>
<el-form-item label="礼金Key">
<el-input v-model="queryParams.giftCouponKey" placeholder="礼金批次ID" clearable size="small" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="商品SKU">
<el-input v-model="queryParams.skuId" placeholder="商品SKU ID" clearable size="small" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="商品名称">
<el-input v-model="queryParams.skuName" placeholder="商品名称" clearable size="small" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="queryParams.owner" placeholder="请选择" clearable size="small">
<el-option label="自营" value="g" />
<el-option label="POP" value="pop" />
</el-select>
</el-form-item>
<el-form-item label="过期状态">
<el-select v-model="queryParams.isExpired" placeholder="请选择" clearable size="small">
<el-option label="未过期" :value="0" />
<el-option label="已过期" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
size="small"
range-separator=""
@change="handleDateRangeChange"
/>
</el-form-item>
<!-- 桌面端搜索按钮 -->
<el-form-item v-if="!expanded">
<el-button type="primary" size="small" icon="el-icon-search" @click="handleQuery">搜索</el-button>
<el-button size="small" icon="el-icon-refresh" @click="resetQuery">重置</el-button>
<el-button type="success" size="small" icon="el-icon-plus" @click="handleCreate">创建礼金</el-button>
<el-button type="warning" size="small" icon="el-icon-download" @click="handleExport" v-hasPermi="['system:giftcoupon:export']">导出</el-button>
</el-form-item>
</el-form>
</template>
</mobile-search-form>
<!-- 操作按钮区域移动端单独显示 -->
<div class="action-buttons-section mobile-only">
<mobile-button-group
:buttons="actionButtons"
:primary-count="2"
/>
</div>
<!-- 桌面端按钮组 -->
<div class="desktop-action-buttons desktop-only">
<el-button type="success" size="small" icon="el-icon-plus" @click="handleCreate">创建礼金</el-button>
<el-button type="warning" size="small" icon="el-icon-download" @click="handleExport" v-hasPermi="['system:giftcoupon:export']">导出</el-button>
</div>
</template>
<!-- 统计信息卡片 -->
<template #statistics>
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="8">
<el-card shadow="hover">
<div style="text-align: center;">
<div style="font-size: 24px; font-weight: bold; color: #409EFF;">{{ statistics.totalCount || 0 }}</div>
<div style="color: #909399; margin-top: 8px;">礼金批次总数</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div style="text-align: center;">
<div style="font-size: 24px; font-weight: bold; color: #67C23A;">{{ statistics.totalUseCount || 0 }}</div>
<div style="color: #909399; margin-top: 8px;">总使用次数</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div style="text-align: center;">
<div style="font-size: 24px; font-weight: bold; color: #E6A23C;">¥{{ formatMoney(statistics.totalOcsAmount || 0) }}</div>
<div style="color: #909399; margin-top: 8px;">总分摊金额</div>
</div>
</el-card>
</el-col>
</el-row>
</template>
<!-- 表格区域 -->
<template #table>
<el-table :data="list" v-loading="loading" border stripe :default-sort="{prop: 'createTime', order: 'descending'}">
<el-table-column label="礼金Key" prop="giftCouponKey" width="180" sortable>
<template slot-scope="scope">
<div>
<span style="margin-right: 8px;">{{ scope.row.giftCouponKey }}</span>
<el-button
type="text"
size="mini"
icon="el-icon-copy-document"
@click="copyToClipboard(scope.row.giftCouponKey)"
title="复制礼金Key"
>
复制
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="商品SKU" prop="skuId" width="120" />
<el-table-column label="商品名称" prop="skuName" min-width="200" show-overflow-tooltip />
<el-table-column label="类型" prop="owner" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.owner === 'g' ? 'success' : 'warning'">
{{ scope.row.owner === 'g' ? '自营' : 'POP' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="使用次数" prop="useCount" width="100" sortable />
<el-table-column label="总分摊金额" prop="totalOcsAmount" width="120" sortable>
<template slot-scope="scope">
¥{{ formatMoney(scope.row.totalOcsAmount || 0) }}
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createTime" width="160" sortable>
<template slot-scope="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="过期时间" prop="expireTime" width="160">
<template slot-scope="scope">
<span v-if="scope.row.expireTime">{{ parseTime(scope.row.expireTime) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="过期状态" prop="isExpired" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.isExpired === 1 ? 'danger' : 'success'">
{{ scope.row.isExpired === 1 ? '已过期' : '未过期' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="120">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 分页区域 -->
<template #pagination>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</template>
</list-layout>
<!-- 创建礼金对话框 -->
<el-dialog title="创建礼金" :visible.sync="createDialogVisible" width="500px" append-to-body>
<el-form :model="createForm" :rules="createRules" ref="createForm" label-width="120px">
<el-form-item label="商品链接/SKU" prop="materialUrl">
<el-input
v-model="createForm.materialUrl"
placeholder="请输入商品链接或SKU ID必须先点击查询按钮获取商品信息"
@keyup.enter.native="queryProductInfo"
>
<el-button slot="append" icon="el-icon-search" @click="queryProductInfo" :loading="queryLoading">查询</el-button>
</el-input>
<div style="color: #E6A23C; font-size: 12px; margin-top: 5px;">
<i class="el-icon-warning"></i>
<strong>重要</strong>请先点击"查询"按钮获取商品信息特别是SKU ID然后再创建礼金直接使用短链可能创建失败
</div>
</el-form-item>
<el-form-item label="商品名称" prop="skuName">
<el-input v-model="createForm.skuName" placeholder="查询商品信息后自动填充" :disabled="true" />
</el-form-item>
<el-form-item label="商品类型" prop="owner">
<el-input :value="createForm.owner === 'g' ? '自营' : (createForm.owner === 'pop' ? 'POP' : '未查询')" disabled>
<template slot="prepend">
<i :class="createForm.owner === 'g' ? 'el-icon-success' : (createForm.owner === 'pop' ? 'el-icon-warning' : 'el-icon-question')"
:style="{color: createForm.owner === 'g' ? '#67C23A' : (createForm.owner === 'pop' ? '#409EFF' : '#909399')}"></i>
</template>
</el-input>
<div style="color: #909399; font-size: 12px; margin-top: 5px;">查询商品信息后自动识别自营/POP</div>
</el-form-item>
<el-form-item label="礼金金额(元)" prop="amount">
<el-input-number v-model="createForm.amount" :min="1" :max="50" :precision="2" :step="0.01" style="width: 100%" />
<div style="color: #909399; font-size: 12px; margin-top: 5px;">范围1-50参考JD项目要求</div>
</el-form-item>
<el-form-item label="礼金数量" prop="quantity">
<el-input-number v-model="createForm.quantity" :min="1" :max="100" style="width: 100%" />
<div style="color: #909399; font-size: 12px; margin-top: 5px;">范围1-100</div>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="createDialogVisible = false"> </el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate"> </el-button>
</div>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog title="礼金详情" :visible.sync="detailVisible" width="1000px" append-to-body>
<div v-if="detailData && detailData.giftCoupon">
<el-tabs v-model="activeDetailTab">
<el-tab-pane label="基本信息" name="info">
<el-descriptions :column="2" border style="margin-top: 20px;">
<el-descriptions-item label="礼金Key" :span="2">
<span style="margin-right: 10px;">{{ detailData.giftCoupon.giftCouponKey }}</span>
<el-button type="text" size="mini" icon="el-icon-copy-document" @click="copyToClipboard(detailData.giftCoupon.giftCouponKey)">复制</el-button>
</el-descriptions-item>
<el-descriptions-item label="商品SKU">{{ detailData.giftCoupon.skuId }}</el-descriptions-item>
<el-descriptions-item label="商品名称" :span="2">{{ detailData.giftCoupon.skuName }}</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag :type="detailData.giftCoupon.owner === 'g' ? 'success' : 'warning'">
{{ detailData.giftCoupon.owner === 'g' ? '自营' : 'POP' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="使用次数">{{ detailData.giftCoupon.useCount }}</el-descriptions-item>
<el-descriptions-item label="总分摊金额">¥{{ formatMoney(detailData.giftCoupon.totalOcsAmount || 0) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ parseTime(detailData.giftCoupon.createTime) }}</el-descriptions-item>
<el-descriptions-item label="过期时间">
<span v-if="detailData.giftCoupon.expireTime">{{ parseTime(detailData.giftCoupon.expireTime) }}</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="过期状态" :span="2">
<el-tag :type="detailData.giftCoupon.isExpired === 1 ? 'danger' : 'success'">
{{ detailData.giftCoupon.isExpired === 1 ? '已过期' : '未过期' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane label="关联订单" name="orders">
<el-table :data="detailData.orders || []" border style="margin-top: 20px;" max-height="400">
<el-table-column label="订单号" prop="orderId" width="200" />
<el-table-column label="商品名称" prop="skuName" min-width="200" show-overflow-tooltip />
<el-table-column label="商品SKU" prop="skuId" width="120" />
<el-table-column label="订单金额" prop="price" width="100">
<template slot-scope="scope">¥{{ formatMoney(scope.row.price || 0) }}</template>
</el-table-column>
<el-table-column label="礼金分摊" prop="giftCouponOcsAmount" width="100">
<template slot-scope="scope">¥{{ formatMoney(scope.row.giftCouponOcsAmount || 0) }}</template>
</el-table-column>
<el-table-column label="下单时间" prop="orderTime" width="160">
<template slot-scope="scope">{{ parseTime(scope.row.orderTime) }}</template>
</el-table-column>
<el-table-column label="短链" width="200">
<template slot-scope="scope">
<div v-if="scope.row.positionId">
<a :href="getShortUrl(scope.row.positionId)" target="_blank" style="margin-right: 8px;">查看短链</a>
<el-button type="text" size="mini" icon="el-icon-copy-document" @click="copyToClipboard(getShortUrl(scope.row.positionId))">复制</el-button>
</div>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<div v-if="!detailData.orders || detailData.orders.length === 0" style="text-align: center; padding: 40px; color: #909399;">
暂无关联订单
</div>
</el-tab-pane>
</el-tabs>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailVisible = false"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listGiftCoupons, getGiftCoupon, getGiftCouponStatistics, exportGiftCoupons } from '@/api/system/giftcoupon'
import { createGiftCoupon, transferWithGift, generatePromotionContent } from '@/api/system/jdorder'
import { mapGetters } from 'vuex'
import ListLayout from '@/components/ListLayout'
import MobileSearchForm from '@/components/MobileSearchForm'
import MobileButtonGroup from '@/components/MobileButtonGroup'
import { parseTime } from '@/utils/ruoyi'
export default {
name: 'GiftCoupon',
components: {
ListLayout,
MobileSearchForm,
MobileButtonGroup
},
data() {
return {
loading: false,
list: [],
total: 0,
dateRange: [],
queryParams: {
pageNum: 1,
pageSize: 50,
giftCouponKey: undefined,
skuId: undefined,
skuName: undefined,
owner: undefined,
isExpired: undefined,
beginTime: null,
endTime: null
},
statistics: {
totalCount: 0,
totalUseCount: 0,
totalOcsAmount: 0
},
detailVisible: false,
detailData: null,
activeDetailTab: 'info',
createDialogVisible: false,
createLoading: false,
createForm: {
materialUrl: '',
skuName: '',
owner: 'g',
amount: 5, // 默认5元
quantity: 10, // 默认10
queryResult: null // 保存查询到的商品信息包含materialUrl等
},
queryLoading: false, // 查询商品信息加载状态
createRules: {
materialUrl: [{ required: true, message: '请输入商品链接或SKU', trigger: 'blur' }],
amount: [{ required: true, message: '请输入礼金金额', trigger: 'blur' }],
quantity: [{ required: true, message: '请输入礼金数量', trigger: 'blur' }]
}
}
},
created() {
this.getList()
this.loadStatistics()
},
methods: {
/** 查询列表 */
getList() {
this.loading = true
listGiftCoupons(this.queryParams).then(res => {
this.list = (res.rows || res.data || [])
this.total = res.total || 0
this.loading = false
}).catch(() => {
this.loading = false
})
},
/** 加载统计信息 */
loadStatistics() {
const params = { ...this.queryParams }
// 统计信息不需要分页参数
delete params.pageNum
delete params.pageSize
getGiftCouponStatistics(params).then(res => {
if (res.code === 200 && res.data) {
this.statistics = {
totalCount: res.data.useCount || 0,
totalUseCount: res.data.useCount || 0,
totalOcsAmount: res.data.totalOcsAmount || 0
}
}
})
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
this.loadStatistics()
},
/** 重置按钮操作 */
resetQuery() {
this.dateRange = []
this.queryParams = {
pageNum: 1,
pageSize: 50,
giftCouponKey: undefined,
skuId: undefined,
skuName: undefined,
owner: undefined,
isExpired: undefined,
beginTime: null,
endTime: null
}
this.handleQuery()
},
/** 处理日期范围变化 */
handleDateRangeChange(val) {
if (val && val.length === 2) {
this.queryParams.beginTime = val[0]
this.queryParams.endTime = val[1]
} else {
this.queryParams.beginTime = null
this.queryParams.endTime = null
}
},
/** 导出按钮操作 */
handleExport() {
const params = { ...this.queryParams }
this.$modal.confirm('是否确认导出所有礼金数据项?').then(() => {
this.exportLoading = true
return exportGiftCoupons(params)
}).then(response => {
this.$download.excel(response, '礼金数据.xlsx')
this.exportLoading = false
}).catch(() => {
this.exportLoading = false
})
},
/** 创建礼金 */
handleCreate() {
this.createForm = {
materialUrl: '',
skuName: '',
owner: 'g',
amount: 5, // 默认5元
quantity: 10, // 默认10
queryResult: null // 查询结果
}
this.createDialogVisible = true
},
/** 查询商品信息参考xbmessage中的流程 */
async queryProductInfo() {
const materialUrl = this.createForm.materialUrl.trim()
if (!materialUrl) {
this.$modal.msgWarning('请输入商品链接或SKU ID')
return
}
this.queryLoading = true
try {
// 调用转链接口查询商品信息
const res = await generatePromotionContent({ promotionContent: materialUrl })
console.log('查询商品信息返回:', res)
if (res && res.code === 200) {
let productInfo = null
// 解析返回结果参考xbmessage中的处理
try {
// 优先使用data如果没有则使用msg根据实际返回格式
let resultStr = res.data || res.msg
// 如果是字符串,需要解析
let parsed = null
if (typeof resultStr === 'string') {
parsed = JSON.parse(resultStr)
} else {
parsed = resultStr
}
console.log('解析后的数据:', parsed)
// 提取第一个商品信息(支持多种返回格式)
if (Array.isArray(parsed) && parsed.length > 0) {
// 格式1直接是数组
productInfo = parsed[0]
} else if (parsed && typeof parsed === 'object') {
// 格式2对象中包含list数组
if (parsed.list && Array.isArray(parsed.list) && parsed.list.length > 0) {
productInfo = parsed.list[0]
}
// 格式3对象中包含data数组
else if (parsed.data && Array.isArray(parsed.data) && parsed.data.length > 0) {
productInfo = parsed.data[0]
}
// 格式4对象本身包含商品信息
else if (parsed.materialUrl || parsed.owner || parsed.skuName) {
productInfo = parsed
}
}
console.log('提取的商品信息:', productInfo)
if (productInfo) {
// 保存完整的查询结果
this.createForm.queryResult = productInfo
// 自动填充商品信息
this.createForm.skuName = productInfo.skuName || productInfo.title || productInfo.productName || productInfo.cleanSkuName || ''
// owner字段'p'表示POP'g'表示自营,其他默认'g'
const ownerValue = productInfo.owner || (productInfo.popId ? 'pop' : 'g') || 'g'
this.createForm.owner = ownerValue === 'p' ? 'pop' : (ownerValue === 'pop' ? 'pop' : 'g')
// 保存查询到的materialUrl、skuId或spuid用于后续创建礼金
// 注意spuid是SPU ID商品IDskuId是SKU ID库存单位ID不是同一个东西
if (productInfo.materialUrl) {
// 保存materialUrl到查询结果中创建礼金时使用
console.log('查询到的materialUrl', productInfo.materialUrl)
}
if (productInfo.skuId || productInfo.skuid) {
// 保存skuIdSKU ID用于创建礼金
const skuIdValue = productInfo.skuId || productInfo.skuid
console.log('查询到的skuIdSKU ID', skuIdValue)
}
if (productInfo.spuid) {
// spuid是SPU ID商品ID不是SKU ID
console.log('查询到的spuidSPU ID不是SKU ID', productInfo.spuid)
}
this.$modal.msgSuccess('商品信息查询成功:' + (this.createForm.owner === 'g' ? '自营' : 'POP'))
} else {
console.warn('未找到商品信息,完整返回:', parsed)
this.$modal.msgWarning('未找到商品信息,请检查链接是否正确')
}
} catch (e) {
console.error('解析商品信息失败', e, '原始数据:', res)
this.$modal.msgWarning('返回数据格式异常:' + e.message + ',请手动填写商品信息')
}
} else {
this.$modal.msgError('查询商品信息失败:' + (res.msg || '未知错误'))
}
} catch (e) {
console.error('查询商品信息异常', e)
this.$modal.msgError('查询失败:' + (e.message || '未知错误'))
} finally {
this.queryLoading = false
}
},
/** 从链接中提取SKU ID */
extractSkuIdFromUrl(url) {
if (!url) return null
// 如果是纯数字,直接返回
if (/^\d+$/.test(url.trim())) {
return url.trim()
}
// 从URL中提取SKU ID
// 京东链接格式https://item.jd.com/100012043978.html 或 jd.com/123456.html
const skuMatch = url.match(/(?:item\.jd\.com|jd\.com)\/(\d+)/i)
if (skuMatch && skuMatch[1]) {
return skuMatch[1]
}
// 如果包含skuId参数
const paramMatch = url.match(/[?&]skuId=(\d+)/i)
if (paramMatch && paramMatch[1]) {
return paramMatch[1]
}
// 无法提取,返回原链接(让后端处理)
return url.trim()
},
/** 提交创建礼金 */
async submitCreate() {
this.$refs.createForm.validate(async (valid) => {
if (!valid) return
// 金额校验1-50元参考JD项目中的isValidAmount方法
if (!this.createForm.amount || this.createForm.amount < 1 || this.createForm.amount > 50) {
this.$modal.msgError('礼金金额必须在1-50元之间')
return
}
if (!this.createForm.quantity || this.createForm.quantity < 1 || this.createForm.quantity > 100) {
this.$modal.msgError('礼金数量必须在1-100之间')
return
}
// 提取SKU ID或使用materialUrl
const materialUrl = this.createForm.materialUrl.trim()
if (!materialUrl) {
this.$modal.msgError('请输入商品链接或SKU ID')
return
}
const skuId = this.extractSkuIdFromUrl(materialUrl)
const isUrl = skuId !== materialUrl
this.createLoading = true
try {
// 构建请求参数参考JD项目的参数格式
const params = {
amount: this.createForm.amount,
quantity: this.createForm.quantity,
owner: this.createForm.owner || 'g',
skuName: this.createForm.skuName || ''
}
// 根据提取结果设置skuId或materialUrl
// 参考京东API文档skuMaterialId支持SKU ID、商品落地页地址如https://item.jd.com/11144230.html或materialUrl
// 优先使用查询商品信息时获取的数据
if (this.createForm.queryResult) {
// 如果查询过商品信息根据商品类型选择优先使用materialUrl或spuid
const queryResult = this.createForm.queryResult
const isPop = this.createForm.owner === 'pop'
// POP商品优先使用materialUrljingfen链接如果没有则使用SKU ID或落地页地址
// 自营商品优先使用skuIdSKU ID如果没有则使用materialUrl
// 注意spuid是SPU ID商品IDskuId是SKU ID库存单位ID创建礼金应该用skuId而不是spuid
if (isPop) {
// POP商品优先使用materialUrl
if (queryResult.materialUrl) {
params.materialUrl = queryResult.materialUrl
console.log('POP商品使用查询到的materialUrljingfen链接', queryResult.materialUrl)
} else if ((queryResult.skuId || queryResult.skuid) && /^\d+$/.test(String(queryResult.skuId || queryResult.skuid))) {
// 如果没有materialUrl使用SKU ID京东API支持
const skuIdValue = queryResult.skuId || queryResult.skuid
params.skuId = String(skuIdValue)
console.log('POP商品materialUrl不可用使用查询到的skuIdSKU ID', skuIdValue)
} else {
// 降级从用户输入的URL中提取SKU ID或使用完整URL
if (isUrl && skuId && /^\d+$/.test(skuId)) {
// 可以使用SKU ID
params.skuId = skuId
console.log('POP商品使用从URL提取的SKU ID', skuId)
} else if (isUrl && materialUrl.includes('item.jd.com')) {
// 商品落地页地址https://item.jd.com/11144230.html也可以
params.materialUrl = materialUrl
console.log('POP商品使用商品落地页地址', materialUrl)
} else if (isUrl) {
params.materialUrl = materialUrl
console.log('POP商品使用用户输入的URL', materialUrl)
} else if (/^\d+$/.test(skuId)) {
params.skuId = skuId
console.log('POP商品使用纯数字SKU ID', skuId)
}
}
} else {
// 自营商品优先使用skuIdSKU ID注意不是spuid
const skuIdValue = queryResult.skuId || queryResult.skuid
if (skuIdValue && /^\d+$/.test(String(skuIdValue))) {
params.skuId = String(skuIdValue)
console.log('自营商品使用查询到的skuIdSKU ID', skuIdValue)
} else if (queryResult.materialUrl) {
// 如果没有spuid使用materialUrl
params.materialUrl = queryResult.materialUrl
console.log('自营商品spuid不可用使用materialUrl', queryResult.materialUrl)
} else {
// 降级从用户输入的URL中提取SKU ID
if (isUrl && skuId && /^\d+$/.test(skuId)) {
params.skuId = skuId
console.log('自营商品使用从URL提取的SKU ID', skuId)
} else if (isUrl) {
params.materialUrl = materialUrl
console.log('自营商品使用用户输入的URL', materialUrl)
} else if (/^\d+$/.test(skuId)) {
params.skuId = skuId
console.log('自营商品使用纯数字SKU ID', skuId)
}
}
}
} else {
// 没有查询过商品信息,使用用户输入的
if (isUrl && skuId && /^\d+$/.test(skuId)) {
// 如果从URL中提取到了纯数字SKU ID优先使用skuId
params.skuId = skuId
console.log('使用从URL提取的SKU ID', skuId)
} else if (isUrl) {
// 如果提取不到纯数字SKU ID使用原始URL作为materialUrl
// 注意短链u.jd.com可能无法用于创建礼金
params.materialUrl = materialUrl
console.log('使用用户输入的URL可能是短链可能不工作', materialUrl)
} else if (/^\d+$/.test(skuId)) {
// 纯数字作为SKU ID
params.skuId = skuId
console.log('使用纯数字SKU ID', skuId)
} else {
// 其他情况作为materialUrl
params.materialUrl = materialUrl
console.log('使用其他格式的URL', materialUrl)
}
}
// 确保必须有skuId或materialUrl之一
if (!params.skuId && !params.materialUrl) {
this.$modal.msgError('无法确定商品标识请先查询商品信息或输入有效的SKU ID')
this.createLoading = false
return
}
// 如果使用的是短链u.jd.com提示必须查询
if (params.materialUrl && params.materialUrl.includes('u.jd.com')) {
this.$modal.msgError('不能直接使用短链创建礼金!请先点击"查询"按钮获取商品信息SKU ID或jingfen链接然后再创建。')
this.createLoading = false
return
}
// 如果没有查询过且使用的是普通链接而不是SKU ID也提示
if (!this.createForm.queryResult && params.materialUrl && !params.skuId) {
const confirmMsg = '检测到您未查询商品信息。建议先点击"查询"按钮获取准确的商品信息SKU ID这样可以提高创建成功率。是否继续使用当前链接创建'
try {
await this.$confirm(confirmMsg, '提示', {
confirmButtonText: '继续创建',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
this.createLoading = false
return
}
}
// 调用创建礼金接口
console.log('创建礼金请求参数:', params)
const res = await createGiftCoupon(params)
console.log('创建礼金返回结果:', res)
// 检查返回结果
if (!res) {
this.$modal.msgError('接口返回为空,请检查网络连接')
return
}
// 判断成功条件code === 200 且 giftCouponKey 不为null
// 注意即使code是200如果giftCouponKey为null也应该视为失败
const hasGiftKey = res.data &&
(res.data.giftCouponKey !== null && res.data.giftCouponKey !== undefined && res.data.giftCouponKey !== '') ||
(res.data.giftKey !== null && res.data.giftKey !== undefined && res.data.giftKey !== '')
const isSuccess = res.code === 200 && hasGiftKey
if (isSuccess) {
// 解析返回的giftCouponKey参考xbmessage中的处理方式
let giftKey = null
// 先打印data的详细信息
console.log('返回的data类型', typeof res.data, 'data内容', res.data)
if (typeof res.data === 'string') {
// 如果是字符串,尝试解析
try {
const parsed = JSON.parse(res.data)
console.log('data字符串解析后', parsed)
giftKey = parsed.giftCouponKey || parsed.giftKey
} catch (e) {
// 如果解析失败,可能就是字符串本身
giftKey = res.data
}
} else if (res.data && typeof res.data === 'object') {
// 尝试多种可能的字段名(优先顺序很重要)
// 1. 直接获取 giftCouponKey但如果是null则不使用
if (res.data.giftCouponKey !== null && res.data.giftCouponKey !== undefined && res.data.giftCouponKey !== '') {
giftKey = res.data.giftCouponKey
}
// 2. 获取 giftKey但如果是null则不使用
else if (res.data.giftKey !== null && res.data.giftKey !== undefined && res.data.giftKey !== '') {
giftKey = res.data.giftKey
}
// 3. 如果data里面还有data对象嵌套情况
else if (res.data.data && typeof res.data.data === 'object') {
if (res.data.data.giftCouponKey !== null && res.data.data.giftCouponKey !== undefined && res.data.data.giftCouponKey !== '') {
giftKey = res.data.data.giftCouponKey
} else if (res.data.data.giftKey !== null && res.data.data.giftKey !== undefined && res.data.data.giftKey !== '') {
giftKey = res.data.data.giftKey
}
}
// 4. 如果data是数组取第一个
else if (Array.isArray(res.data) && res.data.length > 0) {
const first = res.data[0]
if (first.giftCouponKey !== null && first.giftCouponKey !== undefined && first.giftCouponKey !== '') {
giftKey = first.giftCouponKey
} else if (first.giftKey !== null && first.giftKey !== undefined && first.giftKey !== '') {
giftKey = first.giftKey
}
}
}
console.log('解析到的礼金Key', giftKey, '完整data结构', JSON.stringify(res.data, null, 2))
// 如果giftCouponKey是null说明创建可能失败了
if (res.data && res.data.giftCouponKey === null) {
console.error('礼金Key为null创建可能失败。完整返回', JSON.stringify(res, null, 2))
}
if (giftKey) {
// 可选自动生成短链参考JD项目的完整流程
try {
const transferParams = {}
// 优先使用SKU ID作为materialUrl
if (skuId && isUrl) {
transferParams.materialUrl = skuId
} else {
transferParams.materialUrl = materialUrl
}
transferParams.giftCouponKey = giftKey
console.log('转链请求参数:', transferParams)
const tf = await transferWithGift(transferParams)
console.log('转链返回结果:', tf)
if (tf && tf.code === 200) {
let shortUrl = ''
if (typeof tf.data === 'string') {
shortUrl = tf.data
} else if (tf.data && tf.data.shortURL) {
shortUrl = tf.data.shortURL
} else if (tf.data && typeof tf.data === 'object') {
// 尝试其他可能的字段
shortUrl = tf.data.url || tf.data.link || ''
}
if (shortUrl) {
this.$modal.msgSuccess('礼金创建成功!短链:' + shortUrl)
// 复制短链到剪贴板
this.copyToClipboard(shortUrl)
} else {
this.$modal.msgSuccess('礼金创建成功礼金Key' + giftKey)
}
} else {
this.$modal.msgSuccess('礼金创建成功礼金Key' + giftKey)
}
} catch (e) {
console.error('生成短链失败', e)
this.$modal.msgSuccess('礼金创建成功礼金Key' + giftKey + '(短链生成失败)')
}
} else {
// 没有礼金Key但接口返回成功
console.warn('未找到礼金Key完整返回', res)
console.warn('data的完整内容', JSON.stringify(res.data, null, 2))
// 检查是否是null的情况
if (res.data && res.data.giftCouponKey === null) {
this.$modal.msgError('礼金创建失败返回的礼金Key为null。可能是商品链接不正确、商品不支持创建礼金或内部服务异常。请检查商品链接或稍后重试。')
} else {
// 其他情况
this.$modal.msgWarning('礼金创建完成但未在返回数据中找到礼金Key。请查看控制台日志或联系技术支持。')
}
// 即使失败也刷新列表(可能之前创建的会显示)
setTimeout(() => {
this.getList()
this.loadStatistics()
}, 500)
return // 不关闭对话框,让用户知道出问题了
}
// 关闭对话框并刷新
this.createDialogVisible = false
// 延迟刷新,确保用户能看到提示
setTimeout(() => {
this.getList()
this.loadStatistics()
}, 500)
} else {
// 错误处理 - 更详细的错误信息
console.error('创建礼金失败,返回:', res)
let errorMsg = '创建失败'
// 优先显示后端返回的错误信息
if (res.msg) {
errorMsg = res.msg
} else if (res.data && res.data.msg) {
errorMsg = res.data.msg
}
// 如果是code:200但giftCouponKey为null的情况
else if (res.code === 200 && res.data && (res.data.giftCouponKey === null || res.data.giftCouponKey === undefined)) {
// 优先使用后端返回的错误信息
if (res.data.msg) {
errorMsg = res.data.msg
} else {
errorMsg = '礼金创建失败返回的礼金Key为null。可能的原因\n1. 商品不支持创建礼金\n2. 商品类型(自营/POP判断错误当前为' + this.createForm.owner + '\n3. 京东API调用失败\n4. 商品已下架或不存在\n5. SKU ID或商品链接不正确\n\n请检查\n- 商品链接是否正确\n- 商品类型是否匹配查询到的owner是否正确\n- 查看JD项目日志获取详细错误信息\n- 尝试重新查询商品信息'
}
}
// 其他错误情况
else if (res.message) {
errorMsg = res.message
} else if (typeof res === 'string') {
errorMsg = res
}
this.$modal.msgError('创建失败:' + errorMsg)
}
} catch (e) {
console.error('创建礼金异常', e)
let errorMsg = '未知错误'
if (e.response && e.response.data) {
errorMsg = e.response.data.msg || e.response.data.message || JSON.stringify(e.response.data)
} else if (e.message) {
errorMsg = e.message
}
this.$modal.msgError('创建失败:' + errorMsg)
} finally {
this.createLoading = false
}
})
},
/** 查看详情 */
async handleDetail(row) {
this.activeDetailTab = 'info'
this.detailVisible = true
try {
const res = await getGiftCoupon(row.giftCouponKey)
if (res && res.code === 200) {
if (res.data.giftCoupon) {
this.detailData = res.data
} else {
// 兼容直接返回GiftCoupon对象的情况
this.detailData = {
giftCoupon: res.data,
orders: []
}
}
} else {
this.$modal.msgError('获取详情失败')
this.detailVisible = false
}
} catch (e) {
this.$modal.msgError('获取详情失败')
this.detailVisible = false
}
},
/** 生成短链URL */
getShortUrl(positionId) {
// 根据positionId生成短链这里需要根据实际情况调整
if (!positionId) return ''
return `https://u.jd.com/${positionId}`
},
/** 复制到剪贴板 */
copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
this.$modal.msgSuccess('复制成功')
}).catch(() => {
this.fallbackCopyToClipboard(text)
})
} else {
this.fallbackCopyToClipboard(text)
}
},
/** 降级复制方法 */
fallbackCopyToClipboard(text) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
this.$modal.msgSuccess('复制成功')
} catch (err) {
this.$modal.msgError('复制失败')
}
document.body.removeChild(textArea)
},
/** 格式化金额 */
formatMoney(amount) {
if (!amount) return '0.00'
return Number(amount).toFixed(2)
},
/** 格式化时间 */
parseTime
}
}
</script>
<style scoped>
/* 操作按钮区域 */
.action-buttons-section {
margin-top: 12px;
margin-bottom: 12px;
}
/* 移动端和桌面端按钮组显示控制 */
@media (max-width: 768px) {
.desktop-only {
display: none !important;
}
.action-buttons-section.mobile-only {
display: block;
}
}
@media (min-width: 769px) {
.mobile-only {
display: none !important;
}
.desktop-action-buttons.desktop-only {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
}
.desktop-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.el-card {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div class="app-container">
<p class="tip-line">
与后台 <code>[goofish-order-event]</code> INFO <code>erp_goofish_order_event_log</code> 一致可按订单号/来源/说明关键词跨单排查
来源 <code>JD_LOGISTICS_PUSH</code>京东物流扫描服务 Redis企微货主推送触发闲鱼同步前的轨迹可与 <code>REDIS_WAYBILL</code>写入本地运单<code>AUTO_SHIP</code> 连起来看
</p>
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="96px">
<el-form-item label="订单号" prop="orderNo">
<el-input v-model="queryParams.orderNo" placeholder="模糊" clearable style="width: 168px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="orderId" prop="orderId">
<el-input v-model="queryParams.orderId" placeholder="erp_goofish_order.id" clearable style="width: 130px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="AppKey" prop="appKey">
<el-input v-model="queryParams.appKey" placeholder="精确" clearable style="width: 140px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="事件类型" prop="eventType">
<el-select v-model="queryParams.eventType" placeholder="全部" clearable style="width: 140px">
<el-option label="ORDER_SYNC" value="ORDER_SYNC" />
<el-option label="LOGISTICS_SYNC" value="LOGISTICS_SYNC" />
<el-option label="SHIP" value="SHIP" />
</el-select>
</el-form-item>
<el-form-item label="来源" prop="source">
<el-input v-model="queryParams.source" placeholder="JD_LOGISTICS_PUSH、REDIS…" clearable style="width: 148px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="说明关键字" prop="messageKeyword">
<el-input v-model="queryParams.messageKeyword" placeholder="模糊匹配 message" clearable style="width: 180px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="yyyy-MM-dd"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column label="ID" prop="id" width="72" />
<el-table-column label="时间" prop="createTime" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="类型" prop="eventType" width="120" align="center">
<template slot-scope="scope">
<el-tag :type="eventLogTagType(scope.row.eventType)" size="mini">{{ scope.row.eventType }}</el-tag>
</template>
</el-table-column>
<el-table-column label="orderId" prop="orderId" width="88" />
<el-table-column label="订单号" prop="orderNo" min-width="140" show-overflow-tooltip />
<el-table-column label="AppKey" prop="appKey" min-width="120" show-overflow-tooltip />
<el-table-column label="来源" prop="source" width="120" show-overflow-tooltip />
<el-table-column label="说明" prop="message" min-width="260" show-overflow-tooltip />
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listGoofishOrderEventLogPage } from '@/api/jarvis/goofish'
export default {
name: 'ErpGoofishEventLog',
data() {
return {
loading: false,
showSearch: true,
total: 0,
list: [],
dateRange: [],
queryParams: {
pageNum: 1,
pageSize: 20,
orderId: undefined,
orderNo: undefined,
appKey: undefined,
eventType: undefined,
source: undefined,
messageKeyword: undefined
}
}
},
created() {
const q = this.$route.query || {}
if (q.orderNo) {
this.queryParams.orderNo = q.orderNo
}
if (q.orderId) {
this.queryParams.orderId = q.orderId
}
this.getList()
},
methods: {
eventLogTagType(t) {
if (t === 'ORDER_SYNC') return ''
if (t === 'LOGISTICS_SYNC') return 'warning'
if (t === 'SHIP') return 'success'
return 'info'
},
getList() {
this.loading = true
const q = { ...this.queryParams, params: {} }
if (this.queryParams.orderId !== undefined && this.queryParams.orderId !== null && this.queryParams.orderId !== '') {
const n = parseInt(String(this.queryParams.orderId).trim(), 10)
q.orderId = Number.isFinite(n) ? n : undefined
} else {
q.orderId = undefined
}
if (this.dateRange && this.dateRange.length === 2) {
q.params = { beginTime: this.dateRange[0], endTime: this.dateRange[1] }
}
listGoofishOrderEventLogPage(q).then(res => {
this.list = res.rows
this.total = res.total
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.queryParams = {
pageNum: 1,
pageSize: 20,
orderId: undefined,
orderNo: undefined,
appKey: undefined,
eventType: undefined,
source: undefined,
messageKeyword: undefined
}
this.resetForm('queryForm')
this.getList()
}
}
}
</script>
<style scoped>
.tip-line {
margin: 0 0 12px 0;
font-size: 13px;
color: #606266;
line-height: 1.5;
}
.tip-line code {
background: #f4f4f5;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,824 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="96px">
<el-form-item label="AppKey" prop="appKey">
<el-input v-model="queryParams.appKey" placeholder="可选" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="订单号" prop="orderNo">
<el-input v-model="queryParams.orderNo" placeholder="闲鱼订单号" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="订单状态" prop="orderStatus">
<el-select v-model="queryParams.orderStatus" placeholder="全部" clearable style="width: 130px">
<el-option v-for="o in orderStatusOptions" :key="o.v" :label="o.l" :value="o.v" />
</el-select>
</el-form-item>
<el-form-item label="发货状态" prop="shipStatus">
<el-select v-model="queryParams.shipStatus" placeholder="全部" clearable style="width: 120px">
<el-option label="未发货" :value="0" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="info"
plain
icon="el-icon-document"
size="mini"
@click="openEventLogTroubleshoot"
v-hasPermi="['jarvis:erpGoofishOrder:list']"
>变更日志排查</el-button>
<el-button
type="primary"
plain
icon="el-icon-download"
size="mini"
@click="handlePullAll"
v-hasPermi="['jarvis:erpGoofishOrder:edit']"
>增量拉单</el-button>
<el-button
type="warning"
plain
icon="el-icon-time"
size="mini"
@click="handlePullHistoryFull"
v-hasPermi="['jarvis:erpGoofishOrder:edit']"
>历史全量拉单</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="list">
<el-table-column label="订单号" prop="orderNo" min-width="150" show-overflow-tooltip />
<el-table-column label="商品" min-width="260">
<template slot-scope="scope">
<div class="goods-cell">
<el-image
v-if="goodsThumb(scope.row)"
:src="goodsThumb(scope.row)"
:preview-src-list="[goodsThumb(scope.row)]"
fit="cover"
class="goods-thumb"
>
<div slot="error" class="goods-thumb-error"><i class="el-icon-picture-outline" /></div>
</el-image>
<div v-else class="goods-thumb goods-thumb--empty"><i class="el-icon-goods" /></div>
<span class="goods-title-txt" :title="displayGoodsTitle(scope.row)">{{ displayGoodsTitle(scope.row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="买家" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
{{ displayBuyerNick(scope.row) }}
</template>
</el-table-column>
<el-table-column label="订单状态" prop="orderStatus" width="118" align="center">
<template slot-scope="scope">
<el-tag
:type="orderStatusTagType(scope.row.orderStatus)"
:effect="orderStatusTagEffect(scope.row.orderStatus)"
size="small"
disable-transitions
>
{{ orderStatusLabel(scope.row.orderStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="实付" prop="payAmount" width="118" align="right">
<template slot-scope="scope">
{{ formatPayDisplay(scope.row) }}
</template>
</el-table-column>
<el-table-column label="内部单" prop="jdRemark" width="100" show-overflow-tooltip />
<el-table-column label="快递" min-width="130" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="displayExpressName(scope.row) !== '—'">
{{ displayExpressName(scope.row) }}
<template v-if="scope.row.detailExpressCode || detailDataField(scope.row, 'express_code')">
({{ scope.row.detailExpressCode || detailDataField(scope.row, 'express_code') }})
</template>
</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="运单号" min-width="188" align="left">
<template slot-scope="scope">
<span v-for="w in [waybillDisplay(scope.row)]" :key="'wb-' + scope.row.id" class="waybill-cell">
<span v-if="w.kind === 'empty'"></span>
<span v-else-if="w.kind === 'match'" class="waybill-cell--match" :title="w.text">{{ w.text }}</span>
<span v-else-if="w.kind === 'single'" class="waybill-cell--single" :title="w.text">
{{ w.text }}<span v-if="w.source === 'local'" class="waybill-cell--hint">仅本地</span>
</span>
<div v-else class="waybill-cell--warn">
<div class="waybill-cell--warn-title"><i class="el-icon-warning-outline" /> 平台与本地不一致</div>
<div class="waybill-cell--line">平台 {{ w.platform }}</div>
<div class="waybill-cell--line">本地 {{ w.local }}</div>
</div>
</span>
</template>
</el-table-column>
<el-table-column label="收货人/手机" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
{{ displayReceiverName(scope.row) }} {{ displayReceiverMobile(scope.row) }}
</template>
</el-table-column>
<el-table-column label="省/市/区/镇" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">
{{ displayRecvSplit(scope.row) }}
</template>
</el-table-column>
<el-table-column label="闲鱼地址" min-width="130" show-overflow-tooltip>
<template slot-scope="scope">
{{ displayAddressLine(scope.row) }}
</template>
</el-table-column>
<el-table-column label="京东地址" prop="jdAddress" min-width="140" show-overflow-tooltip />
<el-table-column label="发货" prop="shipStatus" width="88">
<template slot-scope="scope">
{{ shipDisplayLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column label="更新时间" prop="modifyTime" width="158" align="center">
<template slot-scope="scope">
{{ formatModifyTime(scope.row.modifyTime) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="278" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="openJson(scope.row)" v-hasPermi="['jarvis:erpGoofishOrder:query']">详情JSON</el-button>
<el-button size="mini" type="text" @click="handleRefresh(scope.row)" v-hasPermi="['jarvis:erpGoofishOrder:edit']">刷新详情</el-button>
<el-button size="mini" type="text" class="goofish-btn-logistics" @click="handleRefreshLogistics(scope.row)" v-hasPermi="['jarvis:erpGoofishOrder:edit']">刷新物流</el-button>
<el-button size="mini" type="text" @click="handleRetryShip(scope.row)" v-hasPermi="['jarvis:erpGoofishOrder:edit']">重试发货</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<!-- 刷新物流结果弹窗与京东订单页一致 append-to-body 避免被表格区裁剪 -->
<el-dialog
title="刷新物流结果"
:visible.sync="refreshLogisticsDialogVisible"
width="640px"
append-to-body
:close-on-click-modal="false"
@closed="onRefreshLogisticsDialogClosed"
>
<div v-loading="refreshLogisticsLoading">
<p v-if="refreshLogisticsOrderNo" class="goofish-rl-order">闲鱼订单号<code>{{ refreshLogisticsOrderNo }}</code></p>
<el-alert
v-if="refreshLogisticsError"
:title="refreshLogisticsError"
type="error"
:closable="false"
show-icon
style="margin-bottom: 12px;"
/>
<template v-else-if="!refreshLogisticsLoading && refreshLogisticsAfter">
<el-alert
:title="refreshLogisticsHasDiff ? '已拉取详情并合并字段,下列有变化' : '已拉取详情,运单/快递等展示字段与刷新前一致'"
:type="refreshLogisticsHasDiff ? 'success' : 'info'"
:closable="false"
style="margin-bottom: 12px;"
/>
<el-table :data="refreshLogisticsCompareRows" size="small" border stripe>
<el-table-column prop="label" label="字段" width="120" />
<el-table-column prop="before" label="刷新前" min-width="160" show-overflow-tooltip />
<el-table-column prop="after" label="刷新后" min-width="160" show-overflow-tooltip />
</el-table>
</template>
</div>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="refreshLogisticsDialogVisible = false"> </el-button>
</div>
</el-dialog>
<el-dialog title="订单详情 JSON含开放平台原始返回" :visible.sync="jsonOpen" width="900px" append-to-body>
<el-tabs v-model="jsonTab" @tab-click="onJsonTabClick">
<el-tab-pane label="摘要" name="sum">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="商品标题 title">
<div class="sum-goods">
<el-image
v-if="goodsThumb(jsonRow)"
:src="goodsThumb(jsonRow)"
:preview-src-list="[goodsThumb(jsonRow)]"
fit="cover"
class="sum-goods-img"
>
<div slot="error" class="goods-thumb-error"><i class="el-icon-picture-outline" /></div>
</el-image>
<span>{{ displayGoodsTitle(jsonRow) }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="运单号(平台 / 本地)">
<span v-for="w in [waybillDisplay(jsonRow)]" :key="'jw'">
<span v-if="w.kind === 'empty'"></span>
<span v-else-if="w.kind === 'match'" class="waybill-cell--match">{{ w.text }}</span>
<span v-else-if="w.kind === 'single'" class="waybill-cell--single">
{{ w.text }}<span v-if="w.source === 'local'" class="waybill-cell--hint">仅本地</span>
</span>
<div v-else class="waybill-cell--warn">
<div class="waybill-cell--warn-title"><i class="el-icon-warning-outline" /> 平台与本地不一致</div>
<div class="waybill-cell--line">平台 {{ w.platform }}</div>
<div class="waybill-cell--line">本地 {{ w.local }}</div>
</div>
</span>
</el-descriptions-item>
<el-descriptions-item label="快递公司 express_name">{{ displayExpressName(jsonRow) }}</el-descriptions-item>
<el-descriptions-item label="实付 pay_amount分→元">{{ formatPayDisplay(jsonRow) }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag
:type="orderStatusTagType(jsonRow.orderStatus)"
:effect="orderStatusTagEffect(jsonRow.orderStatus)"
size="small"
disable-transitions
>
{{ orderStatusLabel(jsonRow.orderStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="买家昵称 buyer_nick">{{ displayBuyerNick(jsonRow) }}</el-descriptions-item>
<el-descriptions-item label="会员名(卖家)">{{ privacyHidden }}</el-descriptions-item>
<el-descriptions-item label="收货人 receiver_name">{{ displayReceiverName(jsonRow) }}</el-descriptions-item>
<el-descriptions-item label="手机 receiver_mobile">{{ displayReceiverMobile(jsonRow) || '—' }}</el-descriptions-item>
<el-descriptions-item label="省/市/区/镇 prov…town">{{ displayRecvSplit(jsonRow) }}</el-descriptions-item>
<el-descriptions-item label="详细地址 address">{{ displayAddressLine(jsonRow) }}</el-descriptions-item>
<el-descriptions-item label="拼接区划 receiver_region">{{ jsonRow.receiverRegion || '—' }}</el-descriptions-item>
<el-descriptions-item label="京东地址">{{ jsonRow.jdAddress || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane label="detail_json" name="detail">
<pre class="json-pre">{{ pretty(jsonRow.detailJson) }}</pre>
</el-tab-pane>
<el-tab-pane label="last_notify_json" name="notify">
<pre class="json-pre">{{ pretty(jsonRow.lastNotifyJson) }}</pre>
</el-tab-pane>
<el-tab-pane label="变更日志" name="events">
<p class="event-log-hint">含状态/平台物流摘要<b>JD_LOGISTICS_PUSH</b>京东物流扫描Redis/企微触达闲鱼<b>REDIS_WAYBILL</b><b>AUTO_SHIP</b> </p>
<el-table v-loading="eventLogsLoading" :data="eventLogs" size="small" max-height="440" empty-text="暂无记录">
<el-table-column label="时间" prop="createTime" width="168" />
<el-table-column label="类型" prop="eventType" width="118" align="center">
<template slot-scope="scope">
<el-tag :type="eventLogTagType(scope.row.eventType)" size="mini">{{ scope.row.eventType }}</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" prop="source" width="126" show-overflow-tooltip />
<el-table-column label="说明" prop="message" min-width="220" show-overflow-tooltip />
</el-table>
</el-tab-pane>
</el-tabs>
<p v-if="jsonRow.shipError" style="color:#f56c6c">发货错误{{ jsonRow.shipError }}</p>
</el-dialog>
</div>
</template>
<script>
import { listGoofishOrder, listGoofishOrderEventLogs, pullAllGoofishOrders, pullAllGoofishOrdersFull, pullGoofishOrdersFull, refreshGoofishDetail, retryGoofishShip, getGoofishOrder } from '@/api/jarvis/goofish'
import { parseTime } from '@/utils/ruoyi'
export default {
name: 'ErpGoofishOrder',
data() {
return {
loading: false,
showSearch: true,
total: 0,
list: [],
queryParams: {
pageNum: 1,
pageSize: 10,
appKey: undefined,
orderNo: undefined,
orderStatus: undefined,
shipStatus: undefined
},
orderStatusOptions: [
{ v: 11, l: '待付款' },
{ v: 12, l: '待发货' },
{ v: 21, l: '已发货' },
{ v: 22, l: '已完成' },
{ v: 23, l: '已退款' },
{ v: 24, l: '已关闭' }
],
/** 摘要中卖家会员名不展示(买家见 displayBuyerNick */
privacyHidden: '—',
jsonOpen: false,
jsonTab: 'sum',
jsonRow: {},
eventLogs: [],
eventLogsLoading: false,
/** 刷新物流结果弹窗 */
refreshLogisticsDialogVisible: false,
refreshLogisticsLoading: false,
refreshLogisticsOrderNo: '',
refreshLogisticsBefore: null,
refreshLogisticsAfter: null,
refreshLogisticsError: ''
}
},
computed: {
refreshLogisticsCompareRows() {
const b = this.refreshLogisticsBefore
const a = this.refreshLogisticsAfter
if (!a && !b) return []
const cell = (row, key, fmt) => {
if (fmt) {
return fmt(row) || '—'
}
if (!row || row[key] == null || row[key] === '') return '—'
return String(row[key])
}
const rows = [
{ label: '平台运单', before: cell(b, 'detailWaybillNo'), after: cell(a, 'detailWaybillNo') },
{ label: '快递编码', before: cell(b, 'detailExpressCode'), after: cell(a, 'detailExpressCode') },
{ label: '快递名称', before: cell(b, 'detailExpressName'), after: cell(a, 'detailExpressName') },
{ label: '本地运单', before: cell(b, 'localWaybillNo'), after: cell(a, 'localWaybillNo') },
{ label: '订单状态', before: cell(b, null, r => this.orderStatusLabel(r && r.orderStatus)), after: cell(a, null, r => this.orderStatusLabel(r && r.orderStatus)) },
{ label: '发货展示', before: cell(b, null, r => this.shipDisplayLabel(r)), after: cell(a, null, r => this.shipDisplayLabel(r)) }
]
return rows
},
refreshLogisticsHasDiff() {
return this.refreshLogisticsCompareRows.some(r => r.before !== r.after)
}
},
created() {
this.getList()
},
methods: {
onRefreshLogisticsDialogClosed() {
this.refreshLogisticsBefore = null
this.refreshLogisticsAfter = null
this.refreshLogisticsError = ''
this.refreshLogisticsOrderNo = ''
},
orderStatusLabel(s) {
const o = this.orderStatusOptions.find(x => x.v === s)
return o ? o.l : (s == null ? '-' : String(s))
},
/** Element 标签类型(仅 success/info/warning/danger与闲管家 order_status 对齐 */
orderStatusTagType(s) {
const m = {
11: 'info',
12: 'warning',
21: 'success',
22: 'success',
23: 'danger',
24: 'info'
}
if (s == null || s === '') return 'info'
return m[s] !== undefined ? m[s] : 'info'
},
/** 已完成用深色实心,与已发货(浅色 success区分 */
orderStatusTagEffect(s) {
return s === 22 ? 'dark' : 'light'
},
/** 开放平台 modify_time秒级时间戳 → 本地 yyyy-MM-dd HH:mm:ss */
formatModifyTime(ts) {
if (ts == null || ts === '') return '-'
return parseTime(ts) || '-'
},
eventLogTagType(t) {
if (t === 'ORDER_SYNC') return 'info'
if (t === 'LOGISTICS_SYNC') return 'warning'
if (t === 'SHIP') return 'warning'
return ''
},
onJsonTabClick(tab) {
if (tab && tab.name === 'events' && this.jsonRow && this.jsonRow.id) {
this.loadEventLogs()
}
},
loadEventLogs() {
if (!this.jsonRow || !this.jsonRow.id) return
this.eventLogsLoading = true
listGoofishOrderEventLogs(this.jsonRow.id).then(res => {
this.eventLogs = Array.isArray(res.data) ? res.data : []
this.eventLogsLoading = false
}).catch(() => {
this.eventLogsLoading = false
})
},
/** 平台侧是否已有发货事实:详情运单号或订单状态已发货/已完成(与 orderStatusOptions 一致) */
hasPlatformShipped(row) {
if (!row) return false
if (this.platformWaybillRaw(row)) return true
const os = row.orderStatus
return os === 21 || os === 22
},
/**
* 发货列展示:本系统开放平台发货成功 → 成功;
* 平台已有物流/已发货但本系统未记成功 → 手动推送(含在闲鱼侧手填物流、或曾自动发货失败后在平台处理等情况);
* 其余按 shipStatus。
*/
shipDisplayLabel(row) {
if (!row) return '未发'
if (row.shipStatus === 1) return '成功'
if (this.hasPlatformShipped(row)) return '手动推送'
if (row.shipStatus === 2) return '失败'
return '未发'
},
detailDataField(row, key) {
if (!row || !row.detailJson) return null
try {
const root = JSON.parse(row.detailJson)
const d = root && root.data ? root.data : root
if (!d || d[key] === undefined || d[key] === null || d[key] === '') return null
return d[key]
} catch (e) {
return null
}
},
/** 订单详情/开放平台 data 根对象(无 detailJson 时 null */
detailOrderData(row) {
if (!row || !row.detailJson) return null
try {
const root = JSON.parse(row.detailJson)
return root && root.data ? root.data : root
} catch (e) {
return null
}
},
firstNonEmptyStr(o, keys) {
if (!o) return null
for (const k of keys) {
const v = o[k]
if (v != null && String(v).trim() !== '') return String(v).trim()
}
return null
},
displayGoodsTitle(row) {
if (!row) return '—'
if (row.goodsTitle) return row.goodsTitle
const t = this.detailDataField(row, 'goods')
if (t && t.title) return t.title
return '—'
},
displayBuyerNick(row) {
if (!row) return '—'
return row.buyerNick || this.detailDataField(row, 'buyer_nick') || '—'
},
/** 收件人:优先订单 detail_json平台再退列表/推送落库字段;不用京东侧兜底(京东地址单列展示) */
displayReceiverName(row) {
if (!row) return '-'
const d = this.detailOrderData(row)
if (d) {
const n = this.firstNonEmptyStr(d, ['receiver_name', 'ship_name', 'consignee_name', 'contact_name'])
if (n) return n
const recv = d.receiver
if (recv && recv.name != null && String(recv.name).trim() !== '') return String(recv.name).trim()
}
if (row.receiverName) return row.receiverName
return '-'
},
displayReceiverMobile(row) {
if (!row) return ''
const d = this.detailOrderData(row)
if (d) {
const m = this.firstNonEmptyStr(d, ['receiver_mobile', 'ship_mobile', 'receiver_phone', 'contact_mobile'])
if (m) return m
const recv = d.receiver
if (recv) {
const mm = recv.mobile || recv.phone
if (mm != null && String(mm).trim() !== '') return String(mm).trim()
}
}
if (row.receiverMobile) return row.receiverMobile
return ''
},
displayRecvSplit(row) {
if (!row) return '—'
const p = row.recvProvName || this.detailDataField(row, 'prov_name')
const c = row.recvCityName || this.detailDataField(row, 'city_name')
const a = row.recvAreaName || this.detailDataField(row, 'area_name')
const t = row.recvTownName || this.detailDataField(row, 'town_name')
const s = [p, c, a, t].filter(x => x != null && String(x).trim() !== '').map(x => String(x).trim()).join(' ')
return s || '—'
},
/** 详细地址:优先平台详情字段,再用列表字段;京东整段地址见「京东地址」列 */
displayAddressLine(row) {
if (!row) return '—'
const d = this.detailOrderData(row)
if (d) {
const a = this.firstNonEmptyStr(d, ['receiver_address', 'ship_address', 'detail_address', 'full_address', 'address'])
if (a) return a
const recv = d.receiver
if (recv && recv.address != null && String(recv.address).trim() !== '') return String(recv.address).trim()
}
if (row.receiverAddress) return row.receiverAddress
return '—'
},
normalizeWaybill(v) {
if (v == null || v === '') return ''
return String(v).trim()
},
/** 开放平台 / 详情中的运单号 */
platformWaybillRaw(row) {
if (!row) return ''
if (row.detailWaybillNo != null && String(row.detailWaybillNo).trim() !== '') {
return this.normalizeWaybill(row.detailWaybillNo)
}
return this.normalizeWaybill(this.detailDataField(row, 'waybill_no'))
},
/** 物流扫描写入 Redis 后同步的本地运单 */
localWaybillRaw(row) {
if (!row || row.localWaybillNo == null) return ''
return this.normalizeWaybill(row.localWaybillNo)
},
/**
* 运单展示:双方一致→绿色单行;仅有一边→单行提示;不一致→红色双行
*/
waybillDisplay(row) {
const platform = this.platformWaybillRaw(row)
const local = this.localWaybillRaw(row)
if (!platform && !local) return { kind: 'empty' }
if (platform && local) {
if (platform === local) return { kind: 'match', text: platform }
return { kind: 'mismatch', platform, local }
}
if (platform) return { kind: 'single', text: platform, source: 'platform' }
return { kind: 'single', text: local, source: 'local' }
},
displayExpressName(row) {
if (!row) return '—'
const name = row.detailExpressName || this.detailDataField(row, 'express_name')
if (name) return name
const code = row.detailExpressCode || this.detailDataField(row, 'express_code')
return code || '—'
},
payAmountFen(row) {
if (!row) return null
if (row.payAmount != null && row.payAmount !== '') {
const n = Number(row.payAmount)
return Number.isNaN(n) ? null : n
}
const v = this.detailDataField(row, 'pay_amount')
if (v == null || v === '') return null
const n = Number(v)
return Number.isNaN(n) ? null : n
},
formatPayDisplay(row) {
const fen = this.payAmountFen(row)
if (fen == null) return '—'
return (fen / 100).toFixed(2) + ' 元'
},
/** 后端 goodsImageUrl无则尝试从 detail_json 补一张(旧数据未落库时) */
goodsThumb(row) {
if (!row) return ''
if (row.goodsImageUrl) return row.goodsImageUrl
const raw = row.detailJson
if (!raw) return ''
try {
const root = JSON.parse(raw)
const data = root && root.data ? root.data : root
const g = data && data.goods
if (!g) return ''
if (g.images && g.images.length) return g.images[0]
return g.image || g.pic || g.cover || ''
} catch (e) {
return ''
}
},
pretty(s) {
if (!s) return '-'
try {
return JSON.stringify(JSON.parse(s), null, 2)
} catch (e) {
return s
}
},
getList() {
this.loading = true
listGoofishOrder(this.queryParams).then(res => {
this.list = res.rows
this.total = res.total
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handlePullAll() {
this.$modal.confirm('按配置 pull-lookback-hours 拉取所有启用账号订单(增量),可能较耗时,是否继续?').then(() => {
return pullAllGoofishOrders()
}).then(res => {
const n = (res && res.data && res.data.processedItems) ? res.data.processedItems : 0
this.$modal.msgSuccess('增量拉单完成,处理项约 ' + n)
this.getList()
}).catch(() => {})
},
handlePullHistoryFull() {
this.$prompt('留空 = 所有启用应用;否则只拉此处填写的 AppKey须已在配置中心启用', '历史全量拉单', {
confirmButtonText: '开始',
cancelButtonText: '取消',
inputPlaceholder: '可选 AppKey',
inputValue: this.queryParams.appKey || ''
}).then(({ value }) => {
const key = value != null ? String(value).trim() : ''
const req = key ? pullGoofishOrdersFull(key) : pullAllGoofishOrdersFull()
return req.then(res => ({ res, key }))
}).then(({ res, key }) => {
const n = (res && res.data && res.data.processedItems) ? res.data.processedItems : 0
const d = res && res.data ? res.data.pullFullHistoryDays : ''
const c = res && res.data ? res.data.pullTimeChunkSeconds : ''
this.$modal.msgSuccess(
(key ? '已按 AppKey 拉取' : '已全部启用账号拉取') + `,处理项约 ${n}pull-full-history-days=${d}, chunk 秒=${c}`
)
this.getList()
}).catch(() => {})
},
handleRefresh(row) {
refreshGoofishDetail(row.id).then(() => {
this.$modal.msgSuccess('已请求刷新详情')
this.getList()
}).catch(() => {})
},
/** 与「刷新详情」同源:拉闲管家详情并合并运单/快递;弹窗展示刷新前后对比 */
handleRefreshLogistics(row) {
if (!row || !row.id) return
this.refreshLogisticsDialogVisible = true
this.refreshLogisticsLoading = true
this.refreshLogisticsError = ''
this.refreshLogisticsAfter = null
this.refreshLogisticsOrderNo = row.orderNo || ''
this.refreshLogisticsBefore = { ...row }
refreshGoofishDetail(row.id)
.then(() => getGoofishOrder(row.id))
.then(res => {
this.refreshLogisticsAfter = res && res.data ? { ...res.data } : null
if (!this.refreshLogisticsAfter) {
this.refreshLogisticsError = '刷新成功但未拿到订单详情,请稍后重试或看列表是否已更新'
} else {
this.$modal.msgSuccess('已更新运单与快递信息')
}
this.getList()
})
.catch(e => {
const msg = (e && e.response && e.response.data && e.response.data.msg) || (e && e.message) || '请求失败,请检查网络或后台日志'
this.refreshLogisticsError = String(msg)
})
.finally(() => {
this.refreshLogisticsLoading = false
})
},
handleRetryShip(row) {
retryGoofishShip(row.id).then(() => {
this.$modal.msgSuccess('已重试发货流程')
this.getList()
})
},
/** 打开「变更日志」排查页:与当前菜单同级路径 erpGoofishEventLog需在菜单管理配置 */
openEventLogTroubleshoot() {
const p = this.$route.path || ''
const i = p.lastIndexOf('/')
const prefix = i > 0 ? p.substring(0, i) : p
const target = prefix + '/erpGoofishEventLog'
const q = {}
if (this.queryParams.orderNo) {
q.orderNo = this.queryParams.orderNo
}
this.$tab.openPage('闲鱼订单变更日志', target, q)
},
openJson(row) {
this.jsonRow = { ...row }
this.jsonTab = 'sum'
this.eventLogs = []
this.jsonOpen = true
}
}
}
</script>
<style scoped>
.goofish-rl-order {
margin: 0 0 12px 0;
font-size: 13px;
color: #606266;
}
.goofish-rl-order code {
background: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.event-log-hint {
margin: 0 0 8px 0;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.json-pre {
max-height: 480px;
overflow: auto;
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
}
.mb8 {
margin-bottom: 8px;
}
.goofish-btn-logistics {
color: #67c23a;
}
.goofish-btn-logistics:hover {
color: #85ce61;
}
.goods-cell {
display: flex;
align-items: center;
gap: 10px;
}
.goods-thumb {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid #ebeef5;
background: #f5f7fa;
}
.goods-thumb--empty {
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
font-size: 20px;
}
.goods-thumb-error {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
font-size: 18px;
}
.goods-title-txt {
flex: 1;
min-width: 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sum-goods {
display: flex;
align-items: flex-start;
gap: 12px;
}
.sum-goods-img {
width: 72px;
height: 72px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.waybill-cell {
display: inline-block;
max-width: 100%;
}
.waybill-cell--match {
color: #67c23a;
font-weight: 500;
}
.waybill-cell--single {
color: #606266;
}
.waybill-cell--hint {
color: #909399;
font-size: 12px;
font-weight: normal;
}
.waybill-cell--warn {
color: #f56c6c;
font-size: 12px;
line-height: 1.45;
}
.waybill-cell--warn-title {
font-weight: 500;
margin-bottom: 2px;
}
.waybill-cell--warn-title .el-icon-warning-outline {
margin-right: 4px;
}
.waybill-cell--line {
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="88px">
<el-form-item label="AppKey" prop="appKey">
<el-input v-model="queryParams.appKey" placeholder="AppKey" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
<el-option label="正常" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['jarvis:erpOpenConfig:add']">新增</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="list">
<el-table-column label="ID" prop="id" width="70" />
<el-table-column label="AppKey" prop="appKey" min-width="140" show-overflow-tooltip />
<el-table-column label="闲鱼会员名" prop="xyUserName" width="120" show-overflow-tooltip />
<el-table-column label="快递编码" prop="expressCode" width="100" show-overflow-tooltip />
<el-table-column label="快递名称" prop="expressName" width="100" show-overflow-tooltip />
<el-table-column label="状态" prop="status" width="72">
<template slot-scope="scope">
<span>{{ scope.row.status === '0' ? '正常' : '停用' }}</span>
</template>
</el-table-column>
<el-table-column label="排序" prop="orderNum" width="70" />
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="160" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['jarvis:erpOpenConfig:edit']">修改</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['jarvis:erpOpenConfig:remove']">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog title="闲管家快递公司(点击行填入表单)" :visible.sync="expressOpen" width="720px" append-to-body @open="loadExpressList">
<p style="margin:0 0 8px;font-size:12px;color:#909399">签名与 POST 体必须为 <code>{}</code>Apifox body 留空会导致签名校验失败</p>
<el-table v-loading="expressLoading" :data="expressList" size="small" max-height="400" @row-click="pickExpress">
<el-table-column prop="code" label="编码(code)" width="140" />
<el-table-column prop="express_name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="express_alias" label="简称" width="100" />
<el-table-column prop="is_hot" label="热门" width="72">
<template slot-scope="scope">{{ scope.row.is_hot ? '是' : '' }}</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-form-item label="AppKey" prop="appKey">
<el-input v-model="form.appKey" placeholder="开放平台 AppKey" :disabled="!!form.id" />
</el-form-item>
<el-form-item label="AppSecret" prop="appSecret">
<el-input v-model="form.appSecret" type="password" show-password placeholder="AppSecret" />
</el-form-item>
<el-form-item label="闲鱼会员名" prop="xyUserName">
<el-input v-model="form.xyUserName" placeholder="展示用,可选" />
</el-form-item>
<el-form-item label="快递编码" prop="expressCode">
<el-input v-model="form.expressCode" placeholder="如日日顺为 rrs以平台列表为准">
<el-button slot="append" type="primary" @click="openExpressDialog">拉取列表</el-button>
</el-input>
</el-form-item>
<el-form-item label="快递名称" prop="expressName">
<el-input v-model="form.expressName" placeholder="如 日日顺" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio label="0">正常</el-radio>
<el-radio label="1">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="orderNum">
<el-input-number v-model="form.orderNum" :min="0" controls-position="right" style="width: 160px" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" rows="2" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listErpOpenConfig, getErpOpenConfig, addErpOpenConfig, updateErpOpenConfig, delErpOpenConfig, listGoofishExpressCompanies } from '@/api/jarvis/goofish'
export default {
name: 'ErpOpenConfig',
data() {
return {
loading: false,
showSearch: true,
total: 0,
list: [],
open: false,
title: '',
queryParams: { pageNum: 1, pageSize: 10, appKey: undefined, status: undefined },
form: {},
expressOpen: false,
expressLoading: false,
expressList: [],
rules: {
appKey: [{ required: true, message: 'AppKey 不能为空', trigger: 'blur' }],
appSecret: [{ required: true, message: 'AppSecret 不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态必选', trigger: 'change' }]
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listErpOpenConfig(this.queryParams).then(res => {
this.list = res.rows
this.total = res.total
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
reset() {
this.form = {
id: undefined,
appKey: undefined,
appSecret: undefined,
xyUserName: undefined,
expressCode: undefined,
expressName: '日日顺',
status: '0',
orderNum: 0,
remark: undefined
}
this.resetForm('form')
},
handleAdd() {
this.reset()
this.open = true
this.title = '新增应用配置'
},
handleUpdate(row) {
this.reset()
const id = row.id
getErpOpenConfig(id).then(res => {
this.form = res.data
this.open = true
this.title = '修改应用配置'
})
},
submitForm() {
this.$refs.form.validate(valid => {
if (!valid) return
if (this.form.id != null) {
updateErpOpenConfig(this.form).then(() => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addErpOpenConfig(this.form).then(() => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
})
},
cancel() {
this.open = false
},
openExpressDialog() {
this.expressOpen = true
},
loadExpressList() {
const appKey = this.form && this.form.appKey ? this.form.appKey : undefined
this.expressLoading = true
listGoofishExpressCompanies(appKey).then(res => {
const data = res.data || {}
this.expressList = data.list || []
this.expressLoading = false
if (!this.expressList.length) {
this.$modal.msgWarning('列表为空或未返回 list请检查后台报错或密钥')
}
}).catch(() => { this.expressLoading = false })
},
pickExpress(row) {
if (!row) return
this.form.expressCode = row.code
this.form.expressName = row.express_name || row.express_alias || ''
this.expressOpen = false
this.$modal.msgSuccess('已填入:' + row.code)
},
handleDelete(row) {
this.$modal.confirm('是否确认删除编号为 ' + row.id + ' 的配置?').then(() => {
return delErpOpenConfig(row.id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
}
}
}
</script>

View File

@@ -0,0 +1,468 @@
<template>
<div class="app-container fadan-quick-record" :class="{ 'fadan-quick-record--mobile': isMobile }">
<el-card class="box-card">
<el-form :model="form" label-position="top" class="fadan-form">
<el-form-item label="分销标记">
<div class="mark-row">
<span class="mark-prefix" aria-hidden="true">F-</span>
<el-input
v-model="form.markSuffix"
class="mark-suffix-input"
placeholder="后缀,如 李旭、闲鱼;留空则仅为 F"
clearable
/>
</div>
</el-form-item>
<el-form-item label="型号" required>
<el-input v-model="form.model" placeholder="必填" clearable />
</el-form-item>
<el-form-item label="下单地址" required>
<el-input
v-model="form.address"
type="textarea"
:rows="2"
placeholder="必填;可粘贴多行,失焦或点击录单时会压成一行(去逗号、多空格合并)"
clearable
@blur="normalizeAddressField"
/>
</el-form-item>
<el-form-item label="转链链接(可空)">
<el-input v-model="form.link" type="textarea" :rows="2" placeholder="可空;留空则后台按型号尝试自动填充京粉链接" clearable />
</el-form-item>
<el-form-item label="第三方单号(选填)">
<el-input
v-model="form.thirdPartyOrderNoText"
placeholder="选填;写入录单文案「第三方单号:」行,可覆盖「生」自动带出内容"
clearable
/>
</el-form-item>
<el-divider content-position="left">落库必填与中控录单一致</el-divider>
<el-form-item label="下单人">
<el-input v-model="form.buyer" placeholder="落库必填" clearable />
</el-form-item>
<el-form-item label="下单付款(元)">
<el-input v-model="form.paymentText" placeholder="落库必填,如 2999.00" clearable />
</el-form-item>
<el-form-item label="后返金额(元)">
<el-input v-model="form.rebateText" placeholder="不写则按 0.00 落库" clearable />
</el-form-item>
<el-form-item label="订单号(京东)">
<el-input v-model="form.orderIdText" placeholder="落库必填,与表单「订单号(需填)」一致" clearable />
</el-form-item>
<el-form-item label="物流链接">
<el-input v-model="form.logisticsLink" type="textarea" :rows="2" placeholder="落库必填" clearable />
</el-form-item>
<div class="btn-row">
<el-button type="primary" size="medium" :loading="loading" @click="generate">录单</el-button>
<el-button size="medium" @click="resetForm">重置表单</el-button>
<el-button v-if="resultText" size="medium" @click="copyResult">复制录单</el-button>
</div>
</el-form>
<template v-if="resultText">
<el-divider content-position="left">录单结果</el-divider>
<el-input v-model="resultText" type="textarea" :rows="isMobile ? 16 : 20" readonly class="result-area" />
</template>
</el-card>
<el-dialog
title="地址/单号重复验证"
:visible.sync="verifyDialogVisible"
:width="dialogWidth"
:close-on-click-modal="false"
:close-on-press-escape="false"
append-to-body
>
<div class="verify-body">
<el-alert :title="verifyMessage" type="warning" :closable="false" />
<div class="verify-code">{{ verifyCode }}</div>
<el-input v-model="verifyInput" placeholder="请输入上方四位验证码" maxlength="4" @keyup.enter.native="handleVerify" />
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="verifyDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="verifyLoading" @click="handleVerify">确认录单</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { executeInstruction, executeInstructionWithForce } from '@/api/system/instruction'
export default {
name: 'FadanQuickRecord',
props: {
isMobile: {
type: Boolean,
default: false
}
},
data() {
return {
form: {
markSuffix: '',
model: '',
address: '',
link: '',
thirdPartyOrderNoText: '',
buyer: '',
paymentText: '',
rebateText: '',
orderIdText: '',
logisticsLink: ''
},
loading: false,
resultText: '',
resultListFromLast: [],
verifyDialogVisible: false,
verifyCode: '',
verifyInput: '',
verifyMessage: '',
verifyLoading: false,
pendingCommand: ''
}
},
computed: {
dialogWidth() {
return this.isMobile ? '92%' : '480px'
}
},
methods: {
buildDistributionMark() {
let s = (this.form.markSuffix || '').trim()
if (s.startsWith('-')) {
s = s.slice(1).trim()
}
if (!s) {
return 'F'
}
return 'F-' + s
},
/** 地址压成一行:换行/多空格合并为单空格,去掉中英文逗号 */
compressAddressOneLine(raw) {
if (raw == null) return ''
return String(raw)
.replace(/[,]/g, '')
.replace(/\s+/g, ' ')
.trim()
},
normalizeAddressField() {
this.form.address = this.compressAddressOneLine(this.form.address)
},
buildCommand() {
const mark = this.buildDistributionMark()
const model = (this.form.model || '').trim()
this.normalizeAddressField()
const address = (this.form.address || '').trim()
if (!model) {
this.$modal.msgError('请填写型号')
return null
}
if (!address) {
this.$modal.msgError('请填写地址')
return null
}
const link = (this.form.link || '').trim()
const lines = ['生', mark, model, link, '1', address]
return lines.join('\n')
},
injectOptional(raw) {
if (!raw) return raw
let t = String(raw).replace(/\r\n/g, '\n')
const buyer = (this.form.buyer || '').trim()
const pay = (this.form.paymentText || '').trim()
const rebate = (this.form.rebateText || '').trim()
const orderId = (this.form.orderIdText || '').trim()
const logistics = (this.form.logisticsLink || '').trim()
const thirdPartyOrderNo = (this.form.thirdPartyOrderNoText || '').trim()
if (thirdPartyOrderNo) {
const re = /(第三方单号:)\n([^\n]*)\n(—————————)/g
t = t.replace(re, (_, a, _line, sep) => `${a}\n${thirdPartyOrderNo}\n${sep}`)
}
if (buyer) {
t = t.replace(/(下单人(需填):)\n\n/, `$1\n${buyer}\n\n`)
}
if (pay) {
t = t.replace(/(下单付款(注意核对):)\n\n/, `$1\n${pay}\n\n`)
}
if (rebate) {
t = t.replace(/(后返金额(注意核对):)\n\n/, `$1\n${rebate}\n\n`)
} else {
t = t.replace(/(后返金额(注意核对):)\n\n/, `$1\n0.00\n\n`)
}
if (orderId) {
t = t.replace(/(订单号(需填):)\n\n/, `$1\n${orderId}\n\n`)
}
if (logistics) {
t = t.replace(/(物流链接(需填):)\n\n/, `$1\n${logistics}\n\n`)
}
return t
},
formBodyLooksPersistable(text) {
const t = (text || '').trim()
return t.startsWith('单')
},
canPersistOrderFields() {
const buyer = (this.form.buyer || '').trim()
const pay = (this.form.paymentText || '').trim()
const orderId = (this.form.orderIdText || '').trim()
const logistics = (this.form.logisticsLink || '').trim()
return !!(buyer && pay && orderId && logistics)
},
formatInstructionData(data) {
if (Array.isArray(data)) {
return data.length ? data.join('\n\n') : ''
}
if (typeof data === 'string') {
return data
}
return ''
},
isPersistFailureText(text) {
const s = String(text || '')
return s.includes('[炸弹]') || s.includes('录单警告')
},
applyPersistResult(mergedBody, persistMsg) {
const msg = persistMsg || ''
this.resultText = (mergedBody || '') + '\n\n--- 落库回执 ---\n' + msg
if (this.isPersistFailureText(msg)) {
this.$modal.msgError('落库未成功,请查看回执')
} else {
this.$modal.msgSuccess('已生成表单并落库')
}
},
extractFirstResponse(data) {
if (Array.isArray(data)) {
return data.length ? String(data[0]) : ''
}
if (typeof data === 'string') {
return data
}
return ''
},
checkDuplicateError(resultList) {
if (!resultList || !resultList.length) return false
for (let i = 0; i < resultList.length; i++) {
const s = resultList[i]
if (typeof s === 'string' && (
s.includes('ERROR_CODE:ADDRESS_DUPLICATE') ||
s.includes('ERROR_CODE:ORDER_NUMBER_DUPLICATE')
)) {
return true
}
}
return false
},
showVerifyDialog(cmd) {
this.pendingCommand = cmd
this.verifyCode = String(Math.floor(1000 + Math.random() * 9000))
this.verifyInput = ''
let hasOrder = false
let hasAddr = false
const list = this.resultListFromLast || []
list.forEach(s => {
if (typeof s !== 'string') return
if (s.includes('ERROR_CODE:ORDER_NUMBER_DUPLICATE')) hasOrder = true
if (s.includes('ERROR_CODE:ADDRESS_DUPLICATE')) hasAddr = true
})
if (hasOrder && hasAddr) {
this.verifyMessage = '检测到订单编号与地址可能重复,输入验证码后可强制录单'
} else if (hasOrder) {
this.verifyMessage = '检测到订单编号可能重复,输入验证码后可强制录单'
} else {
this.verifyMessage = '检测到地址可能重复,输入验证码后可强制录单'
}
this.verifyDialogVisible = true
},
async generate() {
const cmd = this.buildCommand()
if (!cmd) return
this.loading = true
this.resultText = ''
try {
const res = await executeInstruction({ command: cmd })
if (!(res && (res.code === 200 || res.msg === '操作成功'))) {
this.$modal.msgError((res && res.msg) || '生成表单失败')
return
}
const list = Array.isArray(res.data) ? res.data : (res.data ? [String(res.data)] : [])
if (this.checkDuplicateError(list)) {
this.resultListFromLast = list
this.showVerifyDialog(cmd)
return
}
const rawForm = this.extractFirstResponse(res.data)
const merged = this.injectOptional(rawForm)
if (!this.formBodyLooksPersistable(merged)) {
this.resultText = merged
this.$modal.msgError('生成结果不是可落库表单,请重试或联系管理员')
return
}
if (!this.canPersistOrderFields()) {
this.resultText = merged
this.$modal.msgWarning('已生成录单文案。落库需填写:下单人、下单付款、订单号、物流链接(后返可不填,将按 0.00')
return
}
const res2 = await executeInstruction({ command: merged })
if (!(res2 && (res2.code === 200 || res2.msg === '操作成功'))) {
this.$modal.msgError((res2 && res2.msg) || '落库失败')
return
}
this.applyPersistResult(merged, this.formatInstructionData(res2.data))
} catch (e) {
this.$modal.msgError('请求失败')
} finally {
this.loading = false
}
},
async handleVerify() {
if (!this.verifyInput || this.verifyInput.length !== 4) {
this.$modal.msgError('请输入四位验证码')
return
}
if (this.verifyInput !== this.verifyCode) {
this.$modal.msgError('验证码不正确')
this.verifyInput = ''
return
}
this.verifyLoading = true
try {
const res = await executeInstructionWithForce({ command: this.pendingCommand })
if (!(res && (res.code === 200 || res.msg === '操作成功'))) {
this.$modal.msgError((res && res.msg) || '执行失败')
return
}
const merged = this.injectOptional(this.extractFirstResponse(res.data))
if (!this.formBodyLooksPersistable(merged)) {
this.resultText = merged
this.verifyDialogVisible = false
this.$modal.msgError('生成结果不是可落库表单')
return
}
if (!this.canPersistOrderFields()) {
this.resultText = merged
this.verifyDialogVisible = false
this.$modal.msgWarning('已强制生成表单。落库仍需填写:下单人、下单付款、订单号、物流链接')
return
}
const res2 = await executeInstruction({ command: merged })
this.verifyDialogVisible = false
if (!(res2 && (res2.code === 200 || res2.msg === '操作成功'))) {
this.$modal.msgError((res2 && res2.msg) || '落库失败')
return
}
this.applyPersistResult(merged, this.formatInstructionData(res2.data))
} catch (e) {
this.$modal.msgError('请求失败')
} finally {
this.verifyLoading = false
}
},
resetForm() {
this.form = {
markSuffix: '',
model: '',
address: '',
link: '',
thirdPartyOrderNoText: '',
buyer: '',
paymentText: '',
rebateText: '',
orderIdText: '',
logisticsLink: ''
}
this.resultText = ''
},
copyResult() {
const t = this.resultText || ''
if (!t) return
if (navigator.clipboard) {
navigator.clipboard.writeText(t).then(() => {
this.$modal.msgSuccess('已复制')
}).catch(() => this.fallbackCopy(t))
} else {
this.fallbackCopy(t)
}
},
fallbackCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.focus()
ta.select()
try {
document.execCommand('copy')
this.$modal.msgSuccess('已复制')
} catch (e) {
this.$modal.msgError('复制失败')
}
document.body.removeChild(ta)
}
}
}
</script>
<style scoped>
.fadan-quick-record {
padding-bottom: 16px;
}
.fadan-quick-record--mobile .box-card {
margin: 0 12px 20px;
}
.fadan-quick-record:not(.fadan-quick-record--mobile) .box-card {
max-width: 960px;
}
.fadan-form ::v-deep .el-form-item {
margin-bottom: 14px;
}
.mark-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.mark-prefix {
flex-shrink: 0;
color: #606266;
font-weight: 500;
user-select: none;
}
.mark-suffix-input {
flex: 1;
min-width: 0;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.result-area ::v-deep .el-textarea__inner {
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
}
.verify-body .verify-code {
text-align: center;
font-size: 26px;
font-weight: 700;
color: #409eff;
margin: 16px 0;
}
@media (max-width: 768px) {
.fadan-quick-record--mobile .box-card {
margin: 0 8px 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,729 @@
<template>
<el-dialog
:visible.sync="visible"
width="900px"
:close-on-click-modal="false"
@close="handleClose"
top="5vh"
>
<!-- 自定义标题 -->
<div slot="title" class="dialog-title">
<i class="el-icon-user"></i>
<span>分销标识接收人配置</span>
<el-tag v-if="hasConfigured" type="success" size="mini" style="margin-left: 10px;">
<i class="el-icon-success"></i> 已配置 {{ configuredCount }}
</el-tag>
</div>
<div class="config-container" v-loading="loading">
<!-- 左侧配置表单 -->
<div class="config-left">
<div class="config-section">
<div class="section-header">
<i class="el-icon-setting"></i>
<span>分销标识接收人映射</span>
<el-button
type="text"
size="mini"
icon="el-icon-plus"
@click="handleAddConfig"
style="margin-left: auto;"
>
添加配置
</el-button>
</div>
<div class="config-list">
<div
v-for="(item, index) in configList"
:key="`config-${item.configId || item.configKey || index}-${index}`"
class="config-item"
>
<div class="form-field">
<label class="field-label">分销标识</label>
<div class="field-content">
<el-input
v-model="item.distributionMark"
placeholder="请输入分销标识F、PDD、H-TF"
size="small"
style="width: 200px;"
@blur="handleDistributionMarkChange(item, index)"
/>
<el-button
type="danger"
size="mini"
icon="el-icon-delete"
@click="handleRemoveConfig(index)"
style="margin-left: 10px;"
>
删除
</el-button>
</div>
</div>
<div class="form-field">
<label class="field-label">接收人列表</label>
<div class="field-content">
<el-input
v-model="item.touser"
placeholder="请输入接收人列表多个用逗号分隔abc,bcd,efg"
size="small"
/>
<div class="item-hint">
<i class="el-icon-info"></i>
配置说明当订单的分销标识为"{{ item.distributionMark || '分销标识' }}"将发送给这些接收人
</div>
<div class="item-hint" style="margin-top: 4px;">
<i class="el-icon-key"></i>
配置键名logistics.push.touser.{{ item.distributionMark || '分销标识' }}
</div>
</div>
</div>
</div>
<div v-if="configList.length === 0" class="empty-state">
<i class="el-icon-document-add"></i>
<p>暂无配置点击"添加配置"开始设置</p>
</div>
</div>
</div>
</div>
<!-- 右侧状态信息 -->
<div class="config-right">
<!-- 配置状态提示 -->
<div class="status-card">
<div class="status-icon" :class="hasConfigured ? 'success' : 'warning'">
<i :class="hasConfigured ? 'el-icon-success' : 'el-icon-warning'"></i>
</div>
<div class="status-text">
<div class="status-title">{{ hasConfigured ? '配置完成' : '配置未完成' }}</div>
<div class="status-desc">
{{ hasConfigured ? `已配置 ${configuredCount} 个分销标识的接收人` : '请至少配置一个分销标识的接收人' }}
</div>
</div>
</div>
<!-- 快速帮助 -->
<div class="help-card">
<div class="card-header">
<i class="el-icon-question"></i>
<span>配置说明</span>
</div>
<div class="help-content">
<div class="help-item">
<i class="el-icon-check"></i>
<span><strong>分销标识</strong>必须与订单中的分销标识完全匹配区分大小写</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span><strong>接收人列表</strong>企业微信用户ID多个用逗号分隔例如abc,bcd,efg</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>配置会自动保存到系统配置表中</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>推送时会根据订单的分销标识自动匹配对应的接收人列表</span>
</div>
</div>
</div>
<!-- 常见分销标识 -->
<div class="help-card">
<div class="card-header">
<i class="el-icon-collection-tag"></i>
<span>常见分销标识</span>
</div>
<div class="help-content">
<div class="tag-list">
<el-tag
v-for="mark in commonDistributionMarks"
:key="mark"
size="small"
@click="handleQuickAdd(mark)"
style="cursor: pointer; margin: 4px;"
>
{{ mark }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div slot="footer" class="footer-buttons">
<div class="footer-left">
<el-button @click="handleLoadFromOrders" :loading="loadLoading" icon="el-icon-refresh" size="small">
从订单加载分销标识
</el-button>
</div>
<div class="footer-right">
<el-button @click="handleClose" size="small">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saveLoading" size="small">
<i class="el-icon-check"></i> 保存配置
</el-button>
</div>
</div>
</el-dialog>
</template>
<script>
import { listConfig, getConfigKey, addConfig, updateConfig } from '@/api/system/config'
import { listJDOrders } from '@/api/system/jdorder'
export default {
name: 'DistributionMarkTouserConfig',
props: {
value: {
type: Boolean,
default: false
}
},
data() {
return {
visible: false,
configList: [],
saveLoading: false,
loadLoading: false,
loading: false,
commonDistributionMarks: ['F', 'PDD', 'H-TF', 'H', 'TF'],
configKeyPrefix: 'logistics.push.touser.'
}
},
computed: {
hasConfigured() {
return this.configList.some(item =>
item.distributionMark && item.touser && item.distributionMark.trim() && item.touser.trim()
)
},
configuredCount() {
return this.configList.filter(item =>
item.distributionMark && item.touser && item.distributionMark.trim() && item.touser.trim()
).length
}
},
watch: {
value(val) {
this.visible = val
if (val) {
// 延迟一下确保对话框已经打开
this.$nextTick(() => {
this.loadConfig()
})
} else {
// 关闭时清空列表,避免下次打开时显示旧数据
this.configList = []
}
},
visible(val) {
this.$emit('input', val)
}
},
methods: {
/** 加载当前配置 */
async loadConfig() {
this.loading = true
try {
// 先尝试从系统配置中加载已有的配置
// 由于我们不知道有哪些分销标识,先加载所有以 logistics.push.touser. 开头的配置
const res = await listConfig({
configKey: 'logistics.push.touser.',
pageNum: 1,
pageSize: 100
})
console.log('加载配置响应:', res)
// 重置配置列表
this.configList.splice(0, this.configList.length)
if (res && res.code === 200 && res.rows && res.rows.length > 0) {
console.log('原始配置数据:', res.rows)
// 过滤并转换配置项
const configs = res.rows
.filter(item => {
// 确保 configKey 存在且以配置前缀开头,并且不是正好等于前缀(即必须有分销标识后缀)
const isValid = item && item.configKey &&
item.configKey.startsWith(this.configKeyPrefix) &&
item.configKey.length > this.configKeyPrefix.length
if (!isValid && item) {
console.warn('过滤掉的无效配置项:', item)
}
return isValid
})
.map(item => {
const distributionMark = item.configKey.replace(this.configKeyPrefix, '').trim()
const configItem = {
distributionMark: distributionMark,
touser: item.configValue || '',
configId: item.configId,
configKey: item.configKey,
configName: item.configName || `${distributionMark}分销标识接收人`
}
console.log('解析配置项:', item.configKey, '->', configItem)
return configItem
})
.filter(item => {
// 再次过滤,确保分销标识不为空
const isValid = item.distributionMark && item.distributionMark.length > 0
if (!isValid) {
console.warn('过滤掉的分销标识为空的配置项:', item)
}
return isValid
})
// 使用 splice 确保 Vue 2 的响应式更新
this.configList.splice(0, 0, ...configs)
console.log('解析后的配置列表:', this.configList)
console.log('配置列表长度:', this.configList.length)
if (configs.length > 0) {
this.$message.success(`成功加载 ${configs.length} 条配置`)
}
}
// 如果没有配置,至少添加一个空项
if (this.configList.length === 0) {
this.configList.push({
distributionMark: '',
touser: '',
configId: null,
configKey: '',
configName: ''
})
console.log('未找到配置,添加空项')
}
} catch (e) {
console.error('加载配置失败:', e)
this.$message.error('加载配置失败:' + (e.message || '未知错误'))
// 即使加载失败,也至少添加一个空项
if (this.configList.length === 0) {
this.configList.push({
distributionMark: '',
touser: '',
configId: null,
configKey: '',
configName: ''
})
}
} finally {
this.loading = false
}
},
/** 从订单中加载分销标识 */
async handleLoadFromOrders() {
this.loadLoading = true
try {
const res = await listJDOrders({
pageNum: 1,
pageSize: 1000
})
if (res.code === 200 && res.rows) {
// 提取所有不重复的分销标识
const distributionMarks = [...new Set(
res.rows
.map(order => order.distributionMark)
.filter(mark => mark && mark.trim())
)].sort()
// 为每个分销标识添加配置项(如果不存在)
distributionMarks.forEach(mark => {
const exists = this.configList.some(item => item.distributionMark === mark)
if (!exists) {
this.configList.push({
distributionMark: mark,
touser: '',
configId: null,
configKey: this.configKeyPrefix + mark,
configName: `${mark}分销标识接收人`
})
}
})
this.$message.success(`已加载 ${distributionMarks.length} 个分销标识`)
}
} catch (e) {
this.$message.error('加载分销标识失败:' + (e.message || '未知错误'))
} finally {
this.loadLoading = false
}
},
/** 快速添加分销标识 */
handleQuickAdd(mark) {
const exists = this.configList.some(item => item.distributionMark === mark)
if (!exists) {
this.configList.push({
distributionMark: mark,
touser: '',
configId: null,
configKey: this.configKeyPrefix + mark,
configName: `${mark}分销标识接收人`
})
this.$message.success(`已添加分销标识:${mark}`)
} else {
this.$message.info(`分销标识 ${mark} 已存在`)
}
},
/** 添加配置项 */
handleAddConfig() {
this.configList.push({
distributionMark: '',
touser: '',
configId: null,
configKey: '',
configName: ''
})
},
/** 删除配置项 */
handleRemoveConfig(index) {
this.configList.splice(index, 1)
// 如果删除后列表为空,至少保留一个空项
if (this.configList.length === 0) {
this.handleAddConfig()
}
},
/** 分销标识变化时更新配置键名 */
handleDistributionMarkChange(item, index) {
if (item.distributionMark && item.distributionMark.trim()) {
item.configKey = this.configKeyPrefix + item.distributionMark.trim()
item.configName = `${item.distributionMark.trim()}分销标识接收人`
} else {
item.configKey = ''
item.configName = ''
}
},
/** 保存配置 */
async handleSave() {
// 验证配置
const validConfigs = this.configList.filter(item =>
item.distributionMark && item.distributionMark.trim() &&
item.touser && item.touser.trim()
)
if (validConfigs.length === 0) {
this.$message.warning('请至少配置一个有效的分销标识和接收人')
return
}
// 检查是否有重复的分销标识
const marks = validConfigs.map(item => item.distributionMark.trim())
const uniqueMarks = [...new Set(marks)]
if (marks.length !== uniqueMarks.length) {
this.$message.warning('存在重复的分销标识,请检查')
return
}
this.saveLoading = true
try {
// 保存每个配置项
const savePromises = validConfigs.map(async (item) => {
const configData = {
configKey: this.configKeyPrefix + item.distributionMark.trim(),
configName: `${item.distributionMark.trim()}分销标识接收人`,
configValue: item.touser.trim(),
configType: 'N',
remark: `分销标识 ${item.distributionMark.trim()} 对应的企业微信接收人列表`
}
if (item.configId) {
// 更新现有配置
configData.configId = item.configId
return updateConfig(configData)
} else {
// 新增配置
return addConfig(configData)
}
})
await Promise.all(savePromises)
this.$message.success(`配置保存成功!共保存 ${validConfigs.length} 项配置`)
// 重新加载配置
await this.loadConfig()
this.$emit('config-updated')
} catch (e) {
this.$message.error('保存失败:' + (e.message || '未知错误'))
} finally {
this.saveLoading = false
}
},
/** 关闭对话框 */
handleClose() {
this.visible = false
}
}
}
</script>
<style scoped>
/* 标题样式 */
.dialog-title {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 500;
}
.dialog-title i {
margin-right: 8px;
font-size: 18px;
}
/* 容器布局 */
.config-container {
display: flex;
gap: 20px;
min-height: 400px;
}
.config-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 15px;
}
.config-right {
width: 300px;
display: flex;
flex-direction: column;
gap: 15px;
}
/* 配置区块 */
.config-section {
background: #f5f7fa;
border-radius: 6px;
padding: 15px;
}
.section-header {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #e4e7ed;
}
.section-header i {
margin-right: 6px;
font-size: 16px;
color: #409eff;
}
/* 配置列表 */
.config-list {
display: flex;
flex-direction: column;
gap: 15px;
max-height: 500px;
overflow-y: auto;
}
.config-item {
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px;
}
.form-field {
margin-bottom: 18px;
}
.form-field:last-child {
margin-bottom: 0;
}
.field-label {
display: block;
font-size: 14px;
color: #606266;
line-height: 1.5;
padding-bottom: 8px;
font-weight: normal;
}
.field-content {
display: block;
width: 100%;
}
.item-header {
display: flex;
align-items: center;
}
.item-content {
margin-top: 8px;
}
.item-hint {
font-size: 12px;
color: #909399;
margin-top: 6px;
display: flex;
align-items: center;
gap: 4px;
}
.item-hint i {
color: #409eff;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #909399;
}
.empty-state i {
font-size: 48px;
margin-bottom: 10px;
display: block;
}
/* 状态卡片 */
.status-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 20px;
color: white;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.3);
}
.status-card.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.status-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.status-icon.success {
background: rgba(103, 194, 58, 0.2);
}
.status-icon.warning {
background: rgba(230, 162, 60, 0.2);
}
.status-text {
flex: 1;
}
.status-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
}
.status-desc {
font-size: 12px;
opacity: 0.9;
line-height: 1.5;
}
/* 帮助卡片 */
.help-card {
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
}
.card-header {
background: #f5f7fa;
padding: 12px 15px;
font-size: 14px;
font-weight: 500;
color: #303133;
display: flex;
align-items: center;
border-bottom: 1px solid #e4e7ed;
}
.card-header i {
margin-right: 6px;
color: #409eff;
}
.help-content {
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.help-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.help-item i {
color: #67c23a;
margin-top: 2px;
flex-shrink: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* 底部按钮 */
.footer-buttons {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
}
.footer-left,
.footer-right {
display: flex;
gap: 8px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.config-container {
flex-direction: column;
}
.config-right {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,739 @@
<template>
<el-dialog
:visible.sync="visible"
width="900px"
:close-on-click-modal="false"
@close="handleClose"
top="5vh"
>
<!-- 自定义标题 -->
<div slot="title" class="dialog-title">
<i class="el-icon-setting"></i>
<span>H-TF订单自动写入配置</span>
<el-tag v-if="config.isConfigured" type="success" size="mini" style="margin-left: 10px;">
<i class="el-icon-success"></i> 已配置
</el-tag>
<el-tag v-else type="warning" size="mini" style="margin-left: 10px;">
<i class="el-icon-warning"></i> 未配置
</el-tag>
</div>
<div class="config-container">
<!-- 左侧配置表单 -->
<div class="config-left">
<!-- 授权状态 -->
<div class="config-section">
<div class="section-header">
<i class="el-icon-key"></i>
<span>授权状态</span>
</div>
<div class="auth-status">
<el-tag v-if="config.hasAccessToken" type="success" size="medium">
<i class="el-icon-circle-check"></i> {{ config.accessTokenStatus }}
</el-tag>
<el-tag v-else type="danger" size="medium">
<i class="el-icon-circle-close"></i> {{ config.accessTokenStatus }}
</el-tag>
<el-button
v-if="!config.hasAccessToken"
type="primary"
size="small"
icon="el-icon-unlock"
@click="handleAuth"
>
立即授权
</el-button>
</div>
</div>
<!-- 文档配置表单 -->
<div class="config-section">
<div class="section-header">
<i class="el-icon-document"></i>
<span>目标文档</span>
</div>
<el-form ref="form" :model="form" :rules="rules" label-width="100px" size="small">
<el-form-item label="文件ID" prop="fileId">
<el-input
v-model="form.fileId"
placeholder="例如DUW50RUprWXh2TGJK"
clearable
>
<el-button
slot="append"
icon="el-icon-search"
@click="handleFetchSheets"
:disabled="!form.fileId"
>
获取工作表
</el-button>
</el-input>
</el-form-item>
<el-form-item label="工作表ID" prop="sheetId">
<el-select
v-if="sheetList.length > 0"
v-model="form.sheetId"
placeholder="请选择工作表"
style="width: 100%;"
clearable
>
<el-option
v-for="sheet in sheetList"
:key="sheet.sheetId"
:label="sheet.title"
:value="sheet.sheetId"
>
<span style="float: left">{{ sheet.title }}</span>
<span style="float: right; color: #8492a6; font-size: 12px;">{{ sheet.sheetId }}</span>
</el-option>
</el-select>
<el-input
v-else
v-model="form.sheetId"
placeholder="例如BB08J2"
clearable
/>
</el-form-item>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="表头行号" prop="headerRow">
<el-input-number
v-model="form.headerRow"
:min="1"
controls-position="right"
style="width: 100%;"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="起始行号" prop="startRow">
<el-input-number
v-model="form.startRow"
:min="1"
controls-position="right"
style="width: 100%;"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</div>
<!-- 右侧状态信息 -->
<div class="config-right">
<!-- 配置状态提示 -->
<div class="status-card">
<div class="status-icon" :class="config.isConfigured ? 'success' : 'warning'">
<i :class="config.isConfigured ? 'el-icon-success' : 'el-icon-warning'"></i>
</div>
<div class="status-text">
<div class="status-title">{{ config.isConfigured ? '配置完成' : '配置未完成' }}</div>
<div class="status-desc">{{ config.hint }}</div>
</div>
</div>
<!-- 表格行数从接口获取用于决定同步范围 -->
<div v-if="config.progressHint || config.currentProgress" class="progress-card">
<div class="card-header">
<i class="el-icon-data-line"></i>
<span>表格行数</span>
</div>
<div class="progress-content">
<div v-if="config.currentProgress" class="progress-detail">
<div class="progress-item">
<span class="label">当前有数据行数</span>
<span class="value"> {{ config.currentProgress }} 接口获取</span>
</div>
<div class="progress-item">
<span class="label">下次同步起始</span>
<span class="value"> {{ config.nextStartRow != null ? config.nextStartRow : form.startRow }} </span>
</div>
<div class="progress-hint">
<i class="el-icon-info"></i>
由接口实时获取表格行数不再使用本地保存的进度
</div>
</div>
<div v-else class="no-progress">
{{ config.progressHint || '暂无表格行数(请先授权并配置)' }}
</div>
</div>
</div>
<!-- 快速帮助 -->
<div class="help-card">
<div class="card-header">
<i class="el-icon-question"></i>
<span>配置说明</span>
</div>
<div class="help-content">
<div class="help-item">
<i class="el-icon-check"></i>
<span>文件ID从腾讯文档URL中获取</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>点击"获取工作表"自动加载</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>表头行号默认为第2行</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>数据起始行默认为第3行</span>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div slot="footer" class="footer-buttons">
<div class="footer-left">
<el-button @click="showOperationLogs = true" icon="el-icon-document" size="small">
操作日志
</el-button>
<el-button @click="handleTest" :loading="testLoading" icon="el-icon-setting" size="small">
测试配置
</el-button>
</div>
<div class="footer-right">
<el-button @click="handleClear" :loading="clearLoading" type="danger" plain size="small">
清除配置
</el-button>
<el-button @click="handleClose" size="small">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saveLoading" size="small">
<i class="el-icon-check"></i> 保存配置
</el-button>
</div>
</div>
<!-- 操作日志查看对话框 -->
<tencent-doc-operation-logs
v-model="showOperationLogs"
:file-id="form.fileId"
:sheet-id="form.sheetId"
/>
</el-dialog>
</template>
<script>
import {
getAutoWriteConfig,
updateAutoWriteConfig,
testAutoWriteConfig,
clearAutoWriteConfig,
getDocSheetList,
getTencentDocAuthUrl
} from '@/api/jarvis/tendoc'
import TencentDocOperationLogs from './TencentDocOperationLogs'
export default {
name: 'TencentDocAutoWriteConfig',
components: {
TencentDocOperationLogs
},
props: {
value: {
type: Boolean,
default: false
}
},
data() {
return {
showOperationLogs: false,
visible: false,
config: {
hasAccessToken: false,
accessTokenStatus: '未授权',
fileId: '',
sheetId: '',
appId: '',
apiBaseUrl: '',
isConfigured: false,
hint: ''
},
form: {
fileId: '',
sheetId: '',
headerRow: 2,
startRow: 3
},
rules: {
fileId: [
{ required: true, message: '请输入文件ID', trigger: 'blur' }
],
sheetId: [
{ required: true, message: '请输入工作表ID', trigger: 'blur' }
],
headerRow: [
{ required: true, message: '请输入表头行号', trigger: 'blur' },
{ type: 'number', min: 1, message: '表头行号必须大于0', trigger: 'blur' }
],
startRow: [
{ required: true, message: '请输入数据起始行', trigger: 'blur' },
{ type: 'number', min: 1, message: '数据起始行必须大于0', trigger: 'blur' }
]
},
sheetList: [],
saveLoading: false,
testLoading: false,
clearLoading: false
}
},
watch: {
value(val) {
this.visible = val
if (val) {
this.loadConfig()
}
},
visible(val) {
this.$emit('input', val)
}
},
methods: {
/** 加载当前配置 */
async loadConfig() {
try {
const res = await getAutoWriteConfig()
if (res.code === 200 && res.data) {
this.config = res.data
this.form.fileId = res.data.fileId || ''
this.form.sheetId = res.data.sheetId || ''
// 确保 headerRow 和 startRow 是数字类型
this.form.headerRow = parseInt(res.data.headerRow) || 2
this.form.startRow = parseInt(res.data.startRow) || 3
console.log('配置加载成功 - headerRow:', this.form.headerRow, 'startRow:', this.form.startRow)
}
} catch (e) {
this.$message.error('加载配置失败:' + (e.message || '未知错误'))
}
},
/** 打开授权页面 */
async handleAuth() {
try {
const res = await getTencentDocAuthUrl()
if (res.code !== 200 || !res.data) {
this.$message.error('获取授权URL失败')
return
}
const authUrl = res.data
const width = 600
const height = 700
const left = (window.screen.width - width) / 2
const top = (window.screen.height - height) / 2
window.open(
authUrl,
'腾讯文档授权',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
)
this.$message.success('授权页面已打开,请在新窗口中完成授权')
// 1秒后刷新配置状态
setTimeout(() => {
this.loadConfig()
}, 1000)
} catch (e) {
this.$message.error('打开授权页面失败:' + (e.message || '未知错误'))
}
},
/** 获取工作表列表 */
async handleFetchSheets() {
if (!this.form.fileId) {
this.$message.warning('请先输入文件ID')
return
}
try {
this.$message.info('正在获取工作表列表...')
const res = await getDocSheetList(this.form.fileId)
if (res.code === 200 && res.data && res.data.sheets) {
this.sheetList = res.data.sheets
this.$message.success(`获取成功,共 ${this.sheetList.length} 个工作表`)
} else {
this.$message.error('获取工作表列表失败:' + (res.msg || '未知错误'))
}
} catch (e) {
this.$message.error('获取工作表列表失败:' + (e.message || '未知错误'))
}
},
/** 保存配置 */
handleSave() {
this.$refs.form.validate(async valid => {
if (!valid) {
return
}
this.saveLoading = true
try {
const res = await updateAutoWriteConfig({
fileId: this.form.fileId,
sheetId: this.form.sheetId,
headerRow: this.form.headerRow,
startRow: this.form.startRow
})
if (res.code === 200) {
this.$message.success(`配置保存成功!表头第${this.form.headerRow}行,数据从第${this.form.startRow}行开始`)
console.log('配置保存成功 - 保存的值:', {
fileId: this.form.fileId,
sheetId: this.form.sheetId,
headerRow: this.form.headerRow,
startRow: this.form.startRow
})
// 延迟重新加载配置,确保后端已保存
setTimeout(() => {
this.loadConfig()
}, 500)
this.$emit('config-updated')
} else {
this.$message.error('保存失败:' + (res.msg || '未知错误'))
}
} catch (e) {
this.$message.error('保存失败:' + (e.message || '未知错误'))
} finally {
this.saveLoading = false
}
})
},
/** 测试配置 */
async handleTest() {
this.testLoading = true
try {
const res = await testAutoWriteConfig()
if (res.code === 200) {
this.$alert(
'<pre style="text-align: left; max-height: 400px; overflow: auto;">' +
JSON.stringify(res.data, null, 2) +
'</pre>',
'测试成功',
{
dangerouslyUseHTMLString: true,
confirmButtonText: '确定',
type: 'success'
}
)
} else {
this.$message.error('测试失败:' + (res.msg || '未知错误'))
}
} catch (e) {
this.$message.error('测试失败:' + (e.message || '未知错误'))
} finally {
this.testLoading = false
}
},
/** 清除配置 */
async handleClear() {
try {
await this.$confirm('确定要清除配置吗?这不会清除授权令牌。', '提示', {
type: 'warning'
})
this.clearLoading = true
const res = await clearAutoWriteConfig()
if (res.code === 200) {
this.$message.success('配置已清除')
this.form.fileId = ''
this.form.sheetId = ''
this.form.startRow = 3
this.sheetList = []
this.loadConfig()
this.$emit('config-updated')
} else {
this.$message.error('清除失败:' + (res.msg || '未知错误'))
}
} catch (e) {
if (e !== 'cancel') {
this.$message.error('清除失败:' + (e.message || '未知错误'))
}
} finally {
this.clearLoading = false
}
},
/** 关闭对话框 */
handleClose() {
this.visible = false
this.sheetList = []
}
}
}
</script>
<style scoped>
/* 标题样式 */
.dialog-title {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 500;
}
.dialog-title i {
margin-right: 8px;
font-size: 18px;
}
/* 容器布局 */
.config-container {
display: flex;
gap: 20px;
min-height: 400px;
}
.config-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 15px;
}
.config-right {
width: 300px;
display: flex;
flex-direction: column;
gap: 15px;
}
/* 配置区块 */
.config-section {
background: #f5f7fa;
border-radius: 6px;
padding: 15px;
}
.section-header {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #e4e7ed;
}
.section-header i {
margin-right: 6px;
font-size: 16px;
color: #409eff;
}
/* 授权状态 */
.auth-status {
display: flex;
align-items: center;
gap: 10px;
}
/* 状态卡片 */
.status-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 20px;
color: white;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.3);
}
.status-card.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.status-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.status-icon.success {
background: rgba(103, 194, 58, 0.2);
}
.status-icon.warning {
background: rgba(230, 162, 60, 0.2);
}
.status-text {
flex: 1;
}
.status-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
}
.status-desc {
font-size: 12px;
opacity: 0.9;
line-height: 1.5;
}
/* 进度卡片 */
.progress-card {
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
}
.card-header {
background: #f5f7fa;
padding: 12px 15px;
font-size: 14px;
font-weight: 500;
color: #303133;
display: flex;
align-items: center;
border-bottom: 1px solid #e4e7ed;
}
.card-header i {
margin-right: 6px;
color: #409eff;
}
.progress-content {
padding: 15px;
}
.progress-detail {
display: flex;
flex-direction: column;
gap: 10px;
}
.progress-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f0f9ff;
border-radius: 4px;
border-left: 3px solid #409eff;
}
.progress-item .label {
font-size: 13px;
color: #606266;
}
.progress-item .value {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.progress-hint {
font-size: 12px;
color: #909399;
padding: 8px 12px;
background: #fef0f0;
border-radius: 4px;
border-left: 3px solid #f56c6c;
display: flex;
align-items: center;
gap: 5px;
}
.no-progress {
font-size: 13px;
color: #909399;
text-align: center;
padding: 10px;
}
/* 帮助卡片 */
.help-card {
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
}
.help-content {
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.help-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.help-item i {
color: #67c23a;
margin-top: 2px;
flex-shrink: 0;
}
/* 底部按钮 */
.footer-buttons {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
}
.footer-left,
.footer-right {
display: flex;
gap: 8px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.config-container {
flex-direction: column;
}
.config-right {
width: 100%;
}
}
/* Element UI 覆盖样式 */
.config-section >>> .el-form-item {
margin-bottom: 18px;
}
.config-section >>> .el-form-item__label {
font-weight: 500;
color: #606266;
}
.config-section >>> .el-input-number {
width: 100%;
}
</style>

View File

@@ -0,0 +1,316 @@
<template>
<el-dialog
title="腾讯文档操作日志"
:visible.sync="visible"
width="90%"
:close-on-click-modal="false"
@close="handleClose"
v-loading="loading"
element-loading-text="加载中..."
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.9)"
>
<!-- 搜索条件 -->
<el-form :model="queryParams" ref="queryForm" :inline="true" label-width="80px">
<el-form-item label="订单号">
<el-input
v-model="queryParams.orderNo"
placeholder="请输入订单号"
clearable
size="small"
style="width: 200px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="queryParams.operationType" placeholder="请选择" clearable size="small" style="width: 150px">
<el-option label="批量同步" value="BATCH_SYNC" />
<el-option label="单个写入" value="WRITE_SINGLE" />
</el-select>
</el-form-item>
<el-form-item label="操作状态">
<el-select v-model="queryParams.operationStatus" placeholder="请选择" clearable size="small" style="width: 150px">
<el-option label="成功" value="SUCCESS" />
<el-option label="失败" value="FAILED" />
<el-option label="跳过" value="SKIPPED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="small" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="small" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 统计信息卡片 -->
<el-row :gutter="20" style="margin-bottom: 20px">
<el-col :span="6">
<el-card shadow="hover">
<div style="text-align: center">
<div style="font-size: 24px; color: #67C23A; font-weight: bold">{{ statistics.success }}</div>
<div style="color: #909399; margin-top: 8px">成功</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div style="text-align: center">
<div style="font-size: 24px; color: #E6A23C; font-weight: bold">{{ statistics.skipped }}</div>
<div style="color: #909399; margin-top: 8px">跳过</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div style="text-align: center">
<div style="font-size: 24px; color: #F56C6C; font-weight: bold">{{ statistics.failed }}</div>
<div style="color: #909399; margin-top: 8px">失败</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div style="text-align: center">
<div style="font-size: 24px; color: #409EFF; font-weight: bold">{{ statistics.total }}</div>
<div style="color: #909399; margin-top: 8px">总计</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 日志表格 -->
<el-table
:data="logList"
border
stripe
style="width: 100%"
max-height="500"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="操作类型" width="100" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.operationType === 'BATCH_SYNC'" type="primary" size="small">批量同步</el-tag>
<el-tag v-else-if="scope.row.operationType === 'WRITE_SINGLE'" type="success" size="small">单个写入</el-tag>
<el-tag v-else size="small">{{ scope.row.operationType }}</el-tag>
</template>
</el-table-column>
<el-table-column label="订单号" prop="orderNo" width="180" />
<el-table-column label="目标行" prop="targetRow" width="80" align="center" />
<el-table-column label="物流链接" prop="logisticsLink" min-width="200" show-overflow-tooltip>
<template slot-scope="scope">
<a v-if="scope.row.logisticsLink" :href="scope.row.logisticsLink" target="_blank" style="color: #409EFF">
{{ scope.row.logisticsLink }}
</a>
<span v-else style="color: #C0C4CC">-</span>
</template>
</el-table-column>
<el-table-column label="操作状态" width="100" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.operationStatus === 'SUCCESS'" type="success" size="small">成功</el-tag>
<el-tag v-else-if="scope.row.operationStatus === 'FAILED'" type="danger" size="small">失败</el-tag>
<el-tag v-else-if="scope.row.operationStatus === 'SKIPPED'" type="warning" size="small">跳过</el-tag>
<el-tag v-else type="info" size="small">{{ scope.row.operationStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="错误信息" prop="errorMessage" min-width="200" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.errorMessage" style="color: #F56C6C">{{ scope.row.errorMessage }}</span>
<span v-else style="color: #C0C4CC">-</span>
</template>
</el-table-column>
<el-table-column label="操作人" prop="operator" width="100" align="center" />
<el-table-column label="操作时间" prop="createTime" width="160" align="center" />
</el-table>
<!-- 分页 -->
<div style="text-align: right; margin-top: 20px">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="queryParams.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size="queryParams.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
>
</el-pagination>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" icon="el-icon-refresh" @click="handleQuery">刷新</el-button>
</div>
</el-dialog>
</template>
<script>
import { getOperationLogs } from '@/api/jarvis/tendoc'
export default {
name: 'TencentDocOperationLogs',
props: {
value: {
type: Boolean,
default: false
},
fileId: {
type: String,
default: ''
},
sheetId: {
type: String,
default: ''
}
},
data() {
return {
loading: false,
logList: [],
total: 0,
queryParams: {
pageNum: 1,
pageSize: 20,
fileId: '',
sheetId: '',
orderNo: '',
operationType: '',
operationStatus: ''
},
statistics: {
success: 0,
failed: 0,
skipped: 0,
total: 0
}
}
},
computed: {
visible: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
watch: {
value(val) {
if (val) {
this.loading = false // 重置loading状态
this.initData()
this.getList()
} else {
this.loading = false // 关闭时也重置loading
}
}
},
methods: {
initData() {
this.queryParams.fileId = this.fileId
this.queryParams.sheetId = this.sheetId
},
/** 查询日志列表 */
getList() {
this.loading = true
getOperationLogs(this.queryParams).then(res => {
if (res.code === 200) {
this.logList = res.data || []
this.total = this.logList.length
this.calculateStatistics()
} else {
this.$message.error(res.msg || '查询失败')
this.logList = []
this.total = 0
this.calculateStatistics()
}
}).catch(e => {
this.$message.error('查询失败: ' + (e.message || '未知错误'))
console.error('查询操作日志失败', e)
this.logList = []
this.total = 0
this.calculateStatistics()
}).finally(() => {
this.loading = false
})
},
/** 计算统计数据 */
calculateStatistics() {
this.statistics = {
success: 0,
failed: 0,
skipped: 0,
total: this.logList.length
}
this.logList.forEach(log => {
if (log.operationStatus === 'SUCCESS') {
this.statistics.success++
} else if (log.operationStatus === 'FAILED') {
this.statistics.failed++
} else if (log.operationStatus === 'SKIPPED') {
this.statistics.skipped++
}
})
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.queryParams = {
pageNum: 1,
pageSize: 20,
fileId: this.fileId,
sheetId: this.sheetId,
orderNo: '',
operationType: '',
operationStatus: ''
}
this.getList()
},
/** 分页 */
handleSizeChange(val) {
this.queryParams.pageSize = val
this.getList()
},
handleCurrentChange(val) {
this.queryParams.pageNum = val
this.getList()
},
/** 关闭对话框 */
handleClose() {
this.loading = false // 确保关闭loading状态
this.visible = false
}
}
}
</script>
<style scoped>
.el-card {
cursor: default;
}
.el-card:hover {
transform: translateY(-2px);
transition: all 0.3s;
}
</style>

View File

@@ -0,0 +1,984 @@
<template>
<el-dialog
title="腾讯文档推送监控"
:visible="visible"
@update:visible="handleVisibleChange"
:width="isMobile ? '100%' : '1200px'"
:close-on-click-modal="false"
@close="handleClose"
:top="isMobile ? '0' : '5vh'"
:fullscreen="isMobile"
:custom-class="isMobile ? 'mobile-push-monitor-dialog' : ''"
:modal-append-to-body="true"
:append-to-body="true"
>
<div class="push-monitor">
<!-- 倒计时和状态卡片 -->
<el-card class="countdown-card" shadow="hover">
<div class="countdown-header">
<div class="header-left">
<i class="el-icon-timer"></i>
<span class="title">自动推送倒计时</span>
</div>
<div class="header-right">
<el-tag v-if="pushStatus.isScheduled" type="warning" size="medium">
<i class="el-icon-loading"></i> 等待推送中
</el-tag>
<el-tag v-else type="info" size="medium">
<i class="el-icon-circle-check"></i> 无待推送任务
</el-tag>
</div>
</div>
<div class="countdown-content">
<div class="countdown-display" :class="{active: pushStatus.isScheduled}">
<div class="time-box">
<span class="time-value">{{ countdownDisplay.minutes }}</span>
<span class="time-label"></span>
</div>
<span class="time-separator">:</span>
<div class="time-box">
<span class="time-value">{{ countdownDisplay.seconds }}</span>
<span class="time-label"></span>
</div>
</div>
<div class="countdown-info">
<div v-if="pushStatus.scheduledTime" class="info-item">
<i class="el-icon-time"></i>
<span>预计推送时间{{ formatDateTime(pushStatus.scheduledTime) }}</span>
</div>
<div v-if="pushStatus.lastSuccessRecord" class="info-item">
<i class="el-icon-success"></i>
<span>上次推送{{ formatDateTime(pushStatus.lastSuccessRecord.endTime) }}</span>
<el-tag size="mini" type="success" style="margin-left: 10px;">
成功 {{ pushStatus.lastSuccessRecord.successCount }}
</el-tag>
</div>
</div>
<div class="countdown-actions">
<el-button
type="primary"
icon="el-icon-upload2"
:loading="pushing"
:size="isMobile ? 'small' : 'default'"
@click="handleTriggerPushNow"
>
立即推送
</el-button>
<el-button
type="warning"
icon="el-icon-close"
:disabled="!pushStatus.isScheduled"
:size="isMobile ? 'small' : 'default'"
@click="handleCancelPush"
>
取消推送
</el-button>
<el-button
icon="el-icon-refresh"
:size="isMobile ? 'small' : 'default'"
@click="loadPushStatus"
>
刷新状态
</el-button>
</div>
</div>
</el-card>
<!-- 推送记录列表 -->
<el-card class="records-card" shadow="hover">
<div slot="header" class="records-header">
<div class="header-left">
<i class="el-icon-document-copy"></i>
<span class="title">推送记录</span>
<el-tag size="mini" type="info" style="margin-left: 10px;">
{{ batchRecords.length }}
</el-tag>
</div>
<div class="header-right">
<el-button type="text" icon="el-icon-refresh" @click="loadBatchRecords">刷新</el-button>
</div>
</div>
<el-timeline v-if="batchRecords.length > 0">
<el-timeline-item
v-for="record in batchRecords"
:key="record.batchId"
:timestamp="formatDateTime(record.createTime)"
placement="top"
:type="getRecordType(record.status)"
:icon="getRecordIcon(record.status)"
>
<el-card class="record-item" shadow="hover">
<div class="record-summary" @click="toggleRecordDetail(record.batchId)">
<div class="summary-left">
<el-tag :type="getStatusTagType(record.status)" size="small">
{{ getStatusText(record.status) }}
</el-tag>
<span class="trigger-source">
{{ getTriggerSourceText(record.triggerSource) }}
</span>
<span class="record-stats">
<i class="el-icon-check" style="color: #67c23a;"></i> {{ record.successCount }}
<i class="el-icon-remove-outline" style="color: #e6a23c; margin-left: 10px;"></i> {{ record.skipCount }}
<i class="el-icon-close" style="color: #f56c6c; margin-left: 10px;"></i> {{ record.errorCount }}
</span>
</div>
<div class="summary-right">
<span class="record-range"> {{ record.startRow }} - {{ record.endRow }}</span>
<span v-if="record.durationMs" class="record-duration">
耗时 {{ formatDuration(record.durationMs) }}
</span>
<i :class="expandedRecords.includes(record.batchId) ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i>
</div>
</div>
<!-- 详情展开区域 -->
<el-collapse-transition>
<div v-if="expandedRecords.includes(record.batchId)" class="record-detail">
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="record.loadingDetail" class="loading-detail" v-loading="true" element-loading-text="正在加载详情...">
<div style="height: 100px;"></div>
</div>
<template v-else>
<div v-if="record.resultMessage" class="detail-message">
<div class="message-label">结果消息</div>
<div class="message-content">{{ record.resultMessage }}</div>
</div>
<div v-if="record.errorMessage" class="detail-error">
<div class="error-label">错误信息</div>
<div class="error-content">{{ record.errorMessage }}</div>
</div>
<!-- 操作日志列表 -->
<div v-if="record.operationLogs && record.operationLogs.length > 0" class="operation-logs">
<div class="logs-header">
<i class="el-icon-document"></i>
<span>操作日志{{ record.operationLogs.length }} </span>
</div>
<el-table
:data="record.operationLogs"
size="mini"
max-height="300"
stripe
>
<el-table-column prop="orderNo" label="订单号" width="150" />
<el-table-column prop="operationType" label="操作类型" width="100" />
<el-table-column prop="targetRow" label="目标行" width="80" />
<el-table-column prop="logisticsLink" label="物流链接" min-width="150" show-overflow-tooltip />
<el-table-column prop="operationStatus" label="状态" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.operationStatus === 'SUCCESS' ? 'success' : 'danger'" size="mini">
{{ scope.row.operationStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="errorMessage" label="错误信息" min-width="150" show-overflow-tooltip />
</el-table>
</div>
<!-- 暂无操作日志 -->
<div v-else class="no-logs">
<i class="el-icon-info"></i>
<span>暂无操作日志</span>
</div>
</template>
</div>
</el-collapse-transition>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无推送记录"></el-empty>
</el-card>
</div>
</el-dialog>
</template>
<script>
import {
getPushStatus,
getBatchPushRecords,
getBatchPushRecordDetail,
triggerPushNow,
cancelPendingPush
} from '@/api/jarvis/tendoc'
export default {
name: 'TencentDocPushMonitor',
props: {
value: {
type: Boolean,
default: false
}
},
data() {
return {
visible: false,
pushing: false,
pushStatus: {
isScheduled: false,
scheduledTime: null,
remainingSeconds: 0,
remainingMs: 0,
countdownText: '无定时任务',
lastSuccessRecord: null
},
countdownDisplay: {
minutes: '00',
seconds: '00'
},
batchRecords: [],
expandedRecords: [],
countdownTimer: null,
refreshTimer: null
}
},
computed: {
isMobile() {
if (this.$store?.getters?.device === 'mobile') {
return true
}
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return true
}
return false
}
},
watch: {
value(val) {
if (this.visible !== val) {
this.visible = val
if (val) {
this.init()
} else {
this.destroy()
}
}
}
},
methods: {
async init() {
await this.loadPushStatus()
await this.loadBatchRecords()
this.startCountdown()
this.startAutoRefresh()
},
destroy() {
this.stopCountdown()
this.stopAutoRefresh()
},
async loadPushStatus() {
try {
const res = await getPushStatus()
console.log('=== 推送状态响应 ===', res)
if (res.code === 200) {
console.log('推送状态数据:', res.data)
console.log('isScheduled:', res.data.isScheduled)
console.log('remainingSeconds:', res.data.remainingSeconds)
console.log('scheduledTime:', res.data.scheduledTime)
// 重要:使用解构赋值,确保 remainingSeconds 被正确赋值
this.pushStatus = {
...res.data,
remainingSeconds: parseInt(res.data.remainingSeconds) || 0
}
this.updateCountdownDisplay()
console.log('倒计时显示:', this.countdownDisplay)
console.log('pushStatus.remainingSeconds 已更新为:', this.pushStatus.remainingSeconds)
} else {
console.error('API返回错误:', res)
}
} catch (e) {
console.error('加载推送状态失败', e)
}
},
async loadBatchRecords() {
try {
const res = await getBatchPushRecords({ limit: 20 })
console.log('加载推送记录响应:', res)
if (res.code === 200) {
const records = res.data || []
// 确保每条记录都有 operationLogs 字段(即使为空数组)
records.forEach(record => {
if (!record.hasOwnProperty('operationLogs')) {
this.$set(record, 'operationLogs', [])
}
// 重置详情加载标记,允许重新加载
this.$set(record, 'detailLoaded', false)
})
this.batchRecords = records
console.log('推送记录数量:', records.length)
records.forEach(r => {
console.log(`记录 ${r.batchId}: operationLogs数量=${r.operationLogs ? r.operationLogs.length : 'undefined'}`)
})
}
} catch (e) {
console.error('加载推送记录失败', e)
}
},
async handleTriggerPushNow() {
try {
await this.$confirm('确定要立即执行推送吗?', '提示', {
type: 'warning'
})
this.pushing = true
const res = await triggerPushNow()
if (res.code === 200) {
this.$message.success('推送已触发')
setTimeout(() => {
this.loadPushStatus()
this.loadBatchRecords()
}, 2000)
} else {
this.$message.error(res.msg || '触发推送失败')
}
} catch (e) {
if (e !== 'cancel') {
this.$message.error('触发推送失败: ' + (e.message || '未知错误'))
}
} finally {
this.pushing = false
}
},
async handleCancelPush() {
try {
await this.$confirm('确定要取消待推送任务吗?', '提示', {
type: 'warning'
})
const res = await cancelPendingPush()
if (res.code === 200) {
this.$message.success('已取消待推送任务')
this.loadPushStatus()
} else {
this.$message.error(res.msg || '取消失败')
}
} catch (e) {
if (e !== 'cancel') {
this.$message.error('取消失败: ' + (e.message || '未知错误'))
}
}
},
async toggleRecordDetail(batchId) {
const index = this.expandedRecords.indexOf(batchId)
if (index > -1) {
// 收起
this.expandedRecords.splice(index, 1)
} else {
// 展开 - 加载详情
this.expandedRecords.push(batchId)
await this.loadRecordDetail(batchId)
}
},
async loadRecordDetail(batchId) {
try {
const record = this.batchRecords.find(r => r.batchId === batchId)
if (!record) return
// 如果已经明确加载过详情(有 loadingDetail 标记且已完成),则不再重复加载
// 注意:即使 operationLogs 为空数组,也可能是数据确实为空,需要重新加载确认
if (record.detailLoaded) {
return
}
// 显示加载状态
this.$set(record, 'loadingDetail', true)
const res = await getBatchPushRecordDetail(batchId)
console.log('加载推送详情响应:', res)
console.log('响应数据:', JSON.stringify(res.data, null, 2))
if (res.code === 200 && res.data) {
// 更新记录的详细信息
const operationLogs = res.data.operationLogs || []
console.log('操作日志数量:', operationLogs.length, 'batchId:', batchId)
console.log('操作日志详情:', operationLogs)
this.$set(record, 'operationLogs', operationLogs)
this.$set(record, 'errorMessage', res.data.errorMessage)
// 使用 resultMessage 字段,如果没有则使用 remark
this.$set(record, 'resultMessage', res.data.resultMessage || res.data.remark)
// 标记已加载详情
this.$set(record, 'detailLoaded', true)
// 如果操作日志为空,输出警告信息用于调试
if (operationLogs.length === 0) {
console.warn('操作日志为空 - batchId:', batchId, '记录数据:', res.data)
}
} else {
this.$message.warning('加载详情失败: ' + (res.msg || '未知错误'))
}
} catch (e) {
console.error('加载推送详情失败', e)
this.$message.error('加载详情失败: ' + (e.message || '未知错误'))
} finally {
const record = this.batchRecords.find(r => r.batchId === batchId)
if (record) {
this.$set(record, 'loadingDetail', false)
}
}
},
startCountdown() {
this.stopCountdown()
// 立即更新一次显示
this.updateCountdownDisplay()
this.countdownTimer = setInterval(() => {
if (this.pushStatus.remainingSeconds > 0) {
this.pushStatus.remainingSeconds--
this.pushStatus.remainingMs = this.pushStatus.remainingSeconds * 1000
this.updateCountdownDisplay()
} else if (this.pushStatus.isScheduled) {
// 倒计时结束,刷新状态
this.loadPushStatus()
this.loadBatchRecords()
}
}, 1000)
},
stopCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
this.countdownTimer = null
}
},
startAutoRefresh() {
this.stopAutoRefresh()
// 每30秒自动刷新一次状态
this.refreshTimer = setInterval(() => {
this.loadPushStatus()
this.loadBatchRecords()
}, 30000)
},
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
},
updateCountdownDisplay() {
const seconds = this.pushStatus.remainingSeconds || 0
console.log('更新倒计时显示 - remainingSeconds:', seconds)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
this.countdownDisplay.minutes = String(minutes).padStart(2, '0')
this.countdownDisplay.seconds = String(secs).padStart(2, '0')
console.log('倒计时显示更新为:', this.countdownDisplay.minutes + ':' + this.countdownDisplay.seconds)
},
formatDateTime(dateTime) {
if (!dateTime) return '-'
try {
// 处理多种时间格式
const date = new Date(dateTime)
if (isNaN(date.getTime())) return dateTime
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} catch (e) {
console.error('格式化时间失败:', e, dateTime)
return dateTime
}
},
formatDuration(ms) {
if (!ms) return '-'
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return minutes > 0 ? `${minutes}${secs}` : `${secs}`
},
getStatusText(status) {
const statusMap = {
'RUNNING': '执行中',
'SUCCESS': '成功',
'PARTIAL': '部分成功',
'PARTIAL_SUCCESS': '部分成功',
'FAILED': '失败',
'INTERRUPTED': '已中断'
}
return statusMap[status] || status
},
getStatusTagType(status) {
const typeMap = {
'RUNNING': 'warning',
'SUCCESS': 'success',
'PARTIAL': 'warning',
'PARTIAL_SUCCESS': 'warning',
'FAILED': 'danger',
'INTERRUPTED': 'info'
}
return typeMap[status] || 'info'
},
getRecordType(status) {
const typeMap = {
'SUCCESS': 'success',
'PARTIAL': 'warning',
'PARTIAL_SUCCESS': 'warning',
'FAILED': 'danger',
'RUNNING': 'primary',
'INTERRUPTED': 'info'
}
return typeMap[status] || 'info'
},
getRecordIcon(status) {
const iconMap = {
'SUCCESS': 'el-icon-success',
'PARTIAL': 'el-icon-warning',
'PARTIAL_SUCCESS': 'el-icon-warning',
'FAILED': 'el-icon-error',
'RUNNING': 'el-icon-loading',
'INTERRUPTED': 'el-icon-remove-outline'
}
return iconMap[status] || 'el-icon-info'
},
getTriggerSourceText(source) {
const sourceMap = {
'DELAYED_TIMER': '延迟定时器',
'USER': '用户手动',
'SYSTEM': '系统'
}
return sourceMap[source] || source
},
handleClose() {
this.visible = false
this.expandedRecords = []
this.$emit('input', false)
},
handleVisibleChange(val) {
this.visible = val
this.$emit('input', val)
if (!val) {
this.expandedRecords = []
}
}
},
beforeDestroy() {
this.destroy()
}
}
</script>
<style scoped>
.push-monitor {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 倒计时卡片 */
.countdown-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.countdown-card >>> .el-card__body {
padding: 0;
}
.countdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.countdown-header .header-left {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 500;
}
.countdown-header .header-left i {
font-size: 24px;
}
.countdown-content {
padding: 30px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.countdown-display {
display: flex;
align-items: center;
gap: 15px;
font-size: 48px;
font-weight: bold;
opacity: 0.5;
transition: all 0.3s;
}
.countdown-display.active {
opacity: 1;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.time-box {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
}
.time-value {
font-size: 48px;
line-height: 1;
}
.time-label {
font-size: 14px;
margin-top: 5px;
opacity: 0.8;
}
.time-separator {
font-size: 36px;
opacity: 0.6;
}
.countdown-info {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 600px;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
padding: 8px 15px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.info-item i {
font-size: 16px;
}
.countdown-actions {
display: flex;
gap: 10px;
}
/* 推送记录卡片 */
.records-card {
flex: 1;
}
.records-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.records-header .header-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
}
.records-header .header-left i {
font-size: 20px;
color: #409eff;
}
.record-item {
cursor: pointer;
transition: all 0.3s;
}
.record-item:hover {
transform: translateY(-2px);
}
.record-summary {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-left,
.summary-right {
display: flex;
align-items: center;
gap: 15px;
}
.trigger-source {
color: #909399;
font-size: 13px;
}
.record-stats {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
}
.record-range,
.record-duration {
color: #606266;
font-size: 13px;
}
.record-detail {
margin-top: 15px;
}
.detail-message,
.detail-error {
margin-bottom: 15px;
}
.message-label,
.error-label {
font-weight: 500;
margin-bottom: 5px;
color: #606266;
}
.message-content {
padding: 10px;
background: #f0f9ff;
border-left: 3px solid #409eff;
border-radius: 4px;
font-size: 13px;
}
.error-content {
padding: 10px;
background: #fef0f0;
border-left: 3px solid #f56c6c;
border-radius: 4px;
font-size: 13px;
color: #f56c6c;
}
.operation-logs {
margin-top: 15px;
}
.logs-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin-bottom: 10px;
color: #606266;
}
.logs-header i {
color: #409eff;
}
.loading-detail {
text-align: center;
padding: 20px;
color: #909399;
}
.no-logs {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 30px;
color: #909399;
font-size: 14px;
}
.no-logs i {
font-size: 18px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.push-monitor {
gap: 12px;
}
.countdown-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 12px;
}
.countdown-header .header-left {
font-size: 16px;
}
.countdown-header .header-left i {
font-size: 20px;
}
.countdown-content {
padding: 20px 12px 12px;
gap: 15px;
}
.countdown-display {
font-size: 32px;
gap: 10px;
}
.time-box {
min-width: 60px;
padding: 8px 12px;
}
.time-value {
font-size: 32px;
}
.time-separator {
font-size: 24px;
}
.countdown-info {
gap: 8px;
}
.info-item {
font-size: 12px;
padding: 6px 12px;
flex-wrap: wrap;
}
.countdown-actions {
flex-direction: column;
width: 100%;
gap: 8px;
}
.countdown-actions .el-button {
width: 100%;
margin: 0;
}
.records-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.records-header .header-left {
font-size: 14px;
}
.record-summary {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.summary-left,
.summary-right {
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.record-stats {
font-size: 12px;
}
.trigger-source,
.record-range,
.record-duration {
font-size: 12px;
}
}
/* 移动端全屏弹窗样式 */
::v-deep .mobile-push-monitor-dialog {
margin: 0 !important;
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
::v-deep .mobile-push-monitor-dialog .el-dialog {
margin: 0 !important;
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
display: flex;
flex-direction: column;
}
::v-deep .mobile-push-monitor-dialog .el-dialog__body {
flex: 1;
overflow-y: auto;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
::v-deep .mobile-push-monitor-dialog .el-dialog__header {
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
flex-shrink: 0;
}
::v-deep .mobile-push-monitor-dialog .el-dialog__title {
font-size: 16px;
}
</style>

View File

@@ -5,24 +5,45 @@
<span>一键转链</span> <span>一键转链</span>
</div> </div>
<el-form :model="form" label-width="120px"> <el-row :gutter="20">
<el-form-item label="输入内容"> <el-col :span="12">
<el-input <el-form :model="form" label-width="120px" label-position="top">
v-model="form.inputContent" <el-form-item label="输入内容">
type="textarea" <el-input
:rows="6" v-model="form.inputContent"
placeholder="请输入需要转链的内容,如商品链接、商品名称等" type="textarea"
style="width: 100%" :rows="10"
/> placeholder="请输入需要转链的内容,如商品链接、商品名称等"
</el-form-item> style="width: 100%"
/>
<el-form-item> </el-form-item>
<el-button type="primary" @click="handleGenerate" :loading="loading">
生成转链内容 <el-form-item>
</el-button> <el-button type="primary" @click="handleGenerate" :loading="loading">
<el-button @click="handleClear">清空</el-button> 生成转链内容
</el-form-item> </el-button>
</el-form> <el-button @click="handleClear">清空</el-button>
</el-form-item>
</el-form>
</el-col>
<el-col :span="12">
<el-form label-position="top">
<el-form-item label="通用文案">
<el-input
:value="generalCopy"
type="textarea"
:rows="10"
readonly
placeholder="暂无通用文案"
style="width: 100%"
/>
</el-form-item>
<div style="margin-top: 10px;">
<el-button type="success" @click="handleCopyText(generalCopy)" :disabled="!generalCopy">复制通用文案</el-button>
</div>
</el-form>
</el-col>
</el-row>
<div v-if="result" style="margin-top: 20px;"> <div v-if="result" style="margin-top: 20px;">
<h4>转链结果</h4> <h4>转链结果</h4>
@@ -69,6 +90,7 @@
/> />
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
<el-button type="success" @click="handleCopyText(wenan.content || '')">复制此版本</el-button> <el-button type="success" @click="handleCopyText(wenan.content || '')">复制此版本</el-button>
<el-button type="primary" style="margin-left: 8px;" @click="openPublish(product, productIndex)">发品</el-button>
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -95,6 +117,12 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 发品操作 -->
<div style="margin-top: 10px; display:flex; gap:8px;">
<el-button type="primary" @click="openPublish(product, productIndex)">发品</el-button>
<el-button @click="handleAddToFavorites(product)">加入常用</el-button>
</div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
@@ -115,14 +143,21 @@
</div> </div>
</div> </div>
</el-card> </el-card>
<!-- 公共发品对话框组件 -->
<PublishDialog :visible.sync="publishDialogVisible" :initial-data="publishInitialData" @success="handlePublishSuccess" />
</div> </div>
</template> </template>
<script> <script>
import { generatePromotionContent } from "@/api/system/jdorder"; import { generatePromotionContent } from "@/api/system/jdorder";
import { addToFavorites, getBySkuid } from "@/api/system/favoriteProduct";
import { addToFavoritesAfterPublishFromTransfer } from "@/utils/publishHelper";
import PublishDialog from '@/components/PublishDialog.vue'
export default { export default {
name: "Jdorder", name: "Jdorder",
components: { PublishDialog },
data() { data() {
return { return {
form: { form: {
@@ -134,10 +169,134 @@ export default {
// 当前选中的商品 // 当前选中的商品
activeProductTab: 0, activeProductTab: 0,
// 每个商品的文案标签页状态 // 每个商品的文案标签页状态
activeWenanTab: {} activeWenanTab: {},
publishDialogVisible: false,
publishInitialData: {},
// 记录正在发品的商品,用于发品成功后自动加入常用
currentPublishProduct: null,
regionOptions: {
provinces: [],
cities: [],
areas: []
},
categoryOptions: [],
categoryLoading: false,
userNameOptions: [],
userNameLoading: false,
erpAccountsOptions: [],
erpAccountLoading: false,
pvOptions: [],
selectedPv: {},
itemBizTypeOptions: [
{ label: '普通商品', value: 2 },
{ label: '已验货', value: 0 },
{ label: '验货宝', value: 10 },
{ label: '闲鱼优品', value: 19 },
{ label: '闲鱼特卖', value: 24 },
{ label: '品牌捡漏', value: 26 }
],
spBizTypeOptions: [
{ label: '手机', value: 1 },
{ label: '时尚', value: 2 },
{ label: '家电', value: 3 },
{ label: '乐器', value: 8 },
{ label: '数码3C', value: 9 },
{ label: '奢品', value: 16 },
{ label: '母婴', value: 17 },
{ label: '美妆', value: 18 },
{ label: '珠宝', value: 19 },
{ label: '游戏', value: 20 },
{ label: '家居', value: 21 },
{ label: '虚拟', value: 22 },
{ label: '图书', value: 24 },
{ label: '食品', value: 27 },
{ label: '玩具', value: 28 },
{ label: '其他', value: 99 }
],
stuffStatusOptions: [
{ label: '不传', value: null },
{ label: '全新', value: 100 },
{ label: '99新', value: 99 },
{ label: '95新', value: 95 },
{ label: '9成新', value: 90 },
{ label: '8成新', value: 80 },
{ label: '7成新', value: 70 },
{ label: '6成新', value: 60 },
{ label: '5成新', value: 50 }
],
serviceSupportOptions: [
{ label: '七天无理由退货', value: 'SDR' },
{ label: '描述不符包邮退', value: 'NFR' },
{ label: '描述不符全额退(虚拟)', value: 'VNR' },
{ label: '10分钟极速发货(虚拟)', value: 'FD_10MS' },
{ label: '24小时极速发货', value: 'FD_24HS' },
{ label: '48小时极速发货', value: 'FD_48HS' },
{ label: '正品保障', value: 'FD_GPA' }
]
}; };
}, },
mounted() {},
computed: {
// 提取通用文案:优先取第一个商品中类型包含“通用”的文案;否则取第一个文案
generalCopy() {
const data = this.parsedResult;
try {
if (Array.isArray(data) && data.length) {
const productIndex = typeof this.activeProductTab === 'number' ? this.activeProductTab : 0;
const product = data[productIndex] || data[0];
const wenanList = Array.isArray(product && product.wenan) ? product.wenan : [];
if (wenanList.length) {
const found = wenanList.find(w => (w && (w.type || '').includes('通用')));
const chosen = found || wenanList[0];
return (chosen && chosen.content) ? chosen.content : '';
}
}
if (data && data.generalCopy) {
return String(data.generalCopy || '');
}
} catch (e) { /* 忽略提取异常 */ }
return '';
}
},
watch: {
'publishDialog.form.itemBizType'(val) {
// 冗余联动,防止@change未触发的情况
this.onItemBizTypeChange();
},
'publishDialog.form.spBizType'(val) {
this.onSpBizTypeChange();
},
'publishDialog.form.appid'(val) {
this.onAppidChange();
},
'publishDialog.form.channelCatId'(val) {
// 兜底:类目变更(包括编程方式设置)时,自动拉取属性
this.loadProperties();
}
},
methods: { methods: {
async handleAddToFavorites(product) {
try {
const payload = {
skuid: product.skuid || product.skuId || product.spuid || '',
productName: product.skuName || product.title || '',
shopName: product.shopName || '',
productUrl: product.materialUrl || product.url || '',
productImage: Array.isArray(product.images) && product.images.length ? product.images[0] : '',
price: product.price != null ? String(product.price) : (product.lowestCouponPrice != null ? String(product.lowestCouponPrice) : ''),
commissionInfo: product.commission != null ? String(product.commission) : '',
remark: '来自一键转链'
}
const res = await addToFavorites(payload)
if (res && (res.code === 200 || res.msg === '操作成功')) {
this.$modal.msgSuccess('已加入常用');
} else {
this.$modal.msgError(res && res.msg ? res.msg : '加入常用失败');
}
} catch (e) {
this.$modal.msgError('加入常用失败');
}
},
handleGenerate() { handleGenerate() {
if (!this.form.inputContent.trim()) { if (!this.form.inputContent.trim()) {
this.$modal.msgError("请输入需要转链的内容"); this.$modal.msgError("请输入需要转链的内容");
@@ -260,6 +419,269 @@ export default {
handlePreviewImage(imageUrl) { handlePreviewImage(imageUrl) {
window.open(imageUrl, '_blank'); window.open(imageUrl, '_blank');
},
openPublish(product, productIndex) {
const wenanIndex = this.activeWenanTab[productIndex] || 0;
const wenanOptions = Array.isArray(product.wenan) ? product.wenan.map((w, i) => ({ label: w.type || `版本${i+1}`, content: w.content || '' })) : [];
// 记录当前发品的商品
this.currentPublishProduct = product;
this.publishInitialData = {
title: product.skuName || '',
content: (product && product.wenan && product.wenan[wenanIndex] ? (product.wenan[wenanIndex].content || '') : ''),
images: Array.isArray(product.images) ? product.images : [],
originalPrice: this.guessYuanPrice(product),
wenanOptions
};
this.publishDialogVisible = true;
},
async handlePublishSuccess(res) {
try {
const p = this.currentPublishProduct || {};
await addToFavoritesAfterPublishFromTransfer(p, res);
this.$store && this.$store.dispatch && this.$store.dispatch('app/triggerFavoriteProductRefresh');
} catch (e) { }
},
onWenanChange(val) {
if (this.publishDialog.wenanOptions && this.publishDialog.wenanOptions[val]) {
this.publishDialog.form.content = this.publishDialog.wenanOptions[val].content || '';
}
},
selectAllImages(flag) {
(this.publishDialog.productImages || []).forEach(it => { it.selected = !!flag; });
},
invertSelection() {
(this.publishDialog.productImages || []).forEach(it => { it.selected = !it.selected; });
},
// 从解析到的商品信息中推测原价(元)
guessYuanPrice(product) {
// 常见字段尝试price、oriPrice、originPrice、jdPrice、marketPrice 等(单位可能为元)
const candidates = [
product && product.price,
product && product.oriPrice,
product && product.originPrice,
product && product.jdPrice,
product && product.marketPrice,
product && product.opPrice,
].filter(v => v != null);
for (const v of candidates) {
const n = Number(v);
if (!Number.isNaN(n) && n > 0) {
return n;
}
}
return null;
},
async loadProvinces(echo = true) {
try {
const res = await getProvinces();
if (res.code === 200) this.regionOptions.provinces = res.data || []; else this.$modal.msgError(res.msg || '加载省份失败');
} catch (e) { this.$modal.msgError('加载省份失败'); }
if (echo && this.publishDialog.form.province) {
await this.loadCities(this.publishDialog.form.province, true);
} else {
this.regionOptions.cities = []; this.regionOptions.areas = [];
this.publishDialog.form.city = null; this.publishDialog.form.district = null;
}
},
async onProvinceChange() {
const provId = this.publishDialog.form.province;
await this.loadCities(provId, false);
},
async onCityChange() {
const provId = this.publishDialog.form.province; const cityId = this.publishDialog.form.city;
await this.loadAreas(provId, cityId, false);
},
async loadCities(provId, echo = false) {
if (!provId) {
this.regionOptions.cities = []; this.regionOptions.areas = [];
this.publishDialog.form.city = null; this.publishDialog.form.district = null;
return;
}
try {
const res = await getCities(provId);
if (res.code === 200) this.regionOptions.cities = res.data || []; else this.$modal.msgError(res.msg || '加载城市失败');
} catch (e) { this.$modal.msgError('加载城市失败'); }
if (echo && this.publishDialog.form.city) {
await this.loadAreas(provId, this.publishDialog.form.city, true);
} else {
this.regionOptions.areas = []; this.publishDialog.form.district = null;
}
},
async loadAreas(provId, cityId, echo = false) {
if (!provId || !cityId) {
this.regionOptions.areas = []; this.publishDialog.form.district = null;
return;
}
try {
const res = await getAreas(provId, cityId);
if (res.code === 200) this.regionOptions.areas = res.data || []; else this.$modal.msgError(res.msg || '加载区县失败');
} catch (e) { this.$modal.msgError('加载区县失败'); }
if (!echo) {
this.publishDialog.form.district = null;
}
},
async onItemBizTypeChange() {
this.categoryOptions = [];
this.publishDialog.form.channelCatId = '';
await this.loadCategories();
},
async onSpBizTypeChange() {
this.categoryOptions = [];
this.publishDialog.form.channelCatId = '';
await this.loadCategories();
},
async loadCategories() {
const itemBizType = this.publishDialog.form.itemBizType;
const spBizType = this.publishDialog.form.spBizType;
const appid = this.publishDialog.form.appid;
if (!itemBizType) return;
this.categoryLoading = true;
try {
const res = await getCategories({ itemBizType, spBizType, appid });
if (res.code === 200) this.categoryOptions = res.data || []; else this.$modal.msgError(res.msg || '加载类目失败');
} catch (e) { this.$modal.msgError('加载类目失败'); }
this.categoryLoading = false;
// 若已有选中的类目,或列表首项存在,则尝试自动拉取属性
if (this.publishDialog.form.channelCatId) {
this.loadProperties();
} else if (this.categoryOptions.length) {
this.publishDialog.form.channelCatId = this.categoryOptions[0].value;
this.loadProperties();
}
},
async loadUsernames() {
this.userNameLoading = true;
try {
const res = await getUsernames({ pageNum: 1, pageSize: 200, appid: this.publishDialog.form.appid });
if (res.code === 200) this.userNameOptions = res.data || []; else this.$modal.msgError(res.msg || '加载会员名失败');
} catch (e) { this.$modal.msgError('加载会员名失败'); }
this.userNameLoading = false;
if (!this.publishDialog.form.userName && this.userNameOptions.length) {
// 如未选择默认填第一个
this.publishDialog.form.userName = this.userNameOptions[0].value;
}
},
async loadERPAccounts() {
this.erpAccountLoading = true;
try {
const res = await getERPAccounts();
if (res.code === 200) this.erpAccountsOptions = res.data || []; else this.$modal.msgError(res.msg || '加载应用失败');
} catch (e) { this.$modal.msgError('加载应用失败'); }
this.erpAccountLoading = false;
if (!this.publishDialog.form.appid && this.erpAccountsOptions.length) {
this.publishDialog.form.appid = this.erpAccountsOptions[0].value;
}
},
onAppidChange() {
// 切换账号后,重新拉取与账号相关的下拉
this.publishDialog.form.userName = '';
this.loadUsernames();
this.loadCategories();
this.loadProperties();
},
async loadProperties() {
const f = this.publishDialog.form;
if (!f.itemBizType || !f.spBizType || !f.channelCatId) {
this.pvOptions = []; this.selectedPv = {}; return;
}
try {
const res = await getProperties({ itemBizType: f.itemBizType, spBizType: f.spBizType, channelCatId: f.channelCatId, appid: f.appid });
if (res.code === 200) {
this.pvOptions = res.data || [];
const keep = { ...this.selectedPv };
this.selectedPv = {};
(this.pvOptions || []).forEach(p => { if (keep[p.propertyId]) this.selectedPv[p.propertyId] = keep[p.propertyId]; });
} else {
this.$modal.msgError(res.msg || '加载属性失败');
}
} catch (e) {
this.$modal.msgError('加载属性失败');
}
},
submitPublish() {
this.$refs.publishForm.validate(valid => {
if (!valid) return;
const f = this.publishDialog.form;
const selectedImages = (this.publishDialog.productImages || [])
.filter(it => it.selected)
.map(it => it.url)
.filter(Boolean);
const extraImages = String(f.extraImagesText || '')
.split(/\n+/)
.map(s => s.trim())
.filter(Boolean);
const images = [...selectedImages, ...extraImages];
if (!images.length) {
this.$modal.msgError('请至少选择或填写一张图片');
return;
}
let channelPv = undefined;
if (f.channelPvJson && f.channelPvJson.trim()) {
try { channelPv = JSON.parse(f.channelPvJson); } catch (e) { this.$modal.msgError('属性JSON格式不正确'); return; }
}
const payload = {
appid: f.appid || undefined,
title: f.title,
content: f.content,
images: images,
whiteImages: f.whiteImages || undefined,
userName: f.userName,
province: f.province,
city: f.city,
district: f.district,
serviceSupport: (f.serviceSupport && f.serviceSupport.length) ? f.serviceSupport.join(',') : undefined,
price: cents(f.price),
originalPrice: f.originalPrice != null ? cents(f.originalPrice) : undefined,
expressFee: cents(f.expressFee),
stock: f.stock,
outerId: f.outerId || undefined,
itemBizType: f.itemBizType,
spBizType: f.spBizType,
channelCatId: f.channelCatId,
stuffStatus: f.stuffStatus || undefined,
channelPv: channelPv
};
function cents(yuan) {
const n = Number(yuan);
if (Number.isNaN(n)) return undefined;
return Math.round(n * 100);
}
this.publishDialog.loading = true;
createProductByPromotion(payload).then(res => {
this.publishDialog.loading = false;
if (res.code === 200) {
// 成功反馈包含生成的outerId
try {
const outerId = res.data && (res.data.outerId || (res.data.data && res.data.data.outerId))
if (outerId) {
this.$modal.msgSuccess(`发品成功,商家编码:${outerId}`)
} else {
this.$modal.msgSuccess('发品提交成功')
}
} catch (e) {
this.$modal.msgSuccess('发品提交成功')
}
this.publishDialog.visible = false;
} else {
this.$modal.msgError(res.msg || '发品失败');
}
}).catch(err => {
this.publishDialog.loading = false;
console.error('发品失败', err);
this.$modal.msgError('发品失败,请稍后重试');
});
});
} }
} }
}; };
@@ -278,4 +700,28 @@ export default {
.clearfix:after { .clearfix:after {
clear: both; clear: both;
} }
.img-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.img-item {
border: 1px solid #e5e5e5;
border-radius: 4px;
padding: 6px;
text-align: center;
}
.img-item img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 4px;
}
.img-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
}
</style> </style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More