Compare commits

...

222 Commits

Author SHA1 Message Date
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
90 changed files with 24825 additions and 869 deletions

View File

@@ -4,11 +4,9 @@ VUE_APP_TITLE = Jarvis
# 开发环境配置
ENV = 'development'
# Jarvis/开发环境
VUE_APP_BASE_API = ''
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true
# VUE_APP_BASE_API = 'http://134.175.126.60:30313'
VUE_APP_BASE_API=/jarvis-api
# VUE_APP_BASE_API = 'http://127.0.0.1:30313'
port = 8888

View File

@@ -5,7 +5,6 @@ VUE_APP_TITLE = Jarvis
ENV = 'production'
# Jarvis/生产环境
VUE_APP_BASE_API = ''
VUE_APP_BASE_API = 'http://134.175.126.60:30313'
VUE_APP_BASE_API=/jarvis-api
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

161
nginx-https.conf Normal file
View File

@@ -0,0 +1,161 @@
# 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 /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;
# 请求体相关配置重要支持POST请求
proxy_set_header Content-Type $content_type;
proxy_set_header Content-Length $content_length;
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 /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;
# POST请求支持
proxy_set_header Content-Type $content_type;
proxy_set_header Content-Length $content_length;
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;
}
# WPS365 OAuth回调接口必须放在 /jarvis-api/ 之后location / 之前)
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;
# POST请求支持
proxy_set_header Content-Type $content_type;
proxy_set_header Content-Length $content_length;
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

@@ -37,6 +37,7 @@
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"nprogress": "0.2.0",
"pinyin-pro": "^3.27.0",
"quill": "2.0.2",
"screenfull": "5.0.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 http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<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">
<title><%= webpackConfig.name %></title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->

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'
})
}

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

@@ -0,0 +1,145 @@
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'
})
}
// 获取当前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'
})
}

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,29 @@
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
})
}

View File

@@ -0,0 +1,35 @@
import request from '@/utils/request'
// 获取提示词模板列表
export function listPromptTemplates() {
return request({
url: '/jarvis/social-media/prompt/list',
method: 'get'
})
}
// 获取单个提示词模板
export function getPromptTemplate(key) {
return request({
url: '/jarvis/social-media/prompt/' + key,
method: 'get'
})
}
// 保存提示词模板
export function savePromptTemplate(data) {
return request({
url: '/jarvis/social-media/prompt/save',
method: 'post',
data: data
})
}
// 删除提示词模板(恢复默认)
export function deletePromptTemplate(key) {
return request({
url: '/jarvis/social-media/prompt/' + key,
method: 'delete'
})
}

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
})
}

181
src/api/jarvis/wps365.js Normal file
View File

@@ -0,0 +1,181 @@
import request from '@/utils/request'
// ==================== OAuth授权相关 ====================
/**
* 获取WPS365授权URL
*/
export function getWPS365AuthUrl(state) {
return request({
url: '/jarvis/wps365/authUrl',
method: 'get',
params: { state }
})
}
/**
* OAuth回调获取访问令牌
*/
export function getWPS365AccessToken(code) {
return request({
url: '/jarvis/wps365/oauth/callback',
method: 'get',
params: { code }
})
}
/**
* 刷新访问令牌
*/
export function refreshWPS365Token(data) {
return request({
url: '/jarvis/wps365/refreshToken',
method: 'post',
data
})
}
/**
* 获取token状态
*/
export function getWPS365TokenStatus(userId) {
return request({
url: '/jarvis/wps365/tokenStatus',
method: 'get',
params: { userId }
})
}
/**
* 设置token用于手动授权
*/
export function setWPS365Token(data) {
return request({
url: '/jarvis/wps365/setToken',
method: 'post',
data
})
}
// ==================== 用户信息相关 ====================
/**
* 获取用户信息
*/
export function getWPS365UserInfo(userId) {
return request({
url: '/jarvis/wps365/userInfo',
method: 'get',
params: { userId }
})
}
// ==================== 文件相关 ====================
/**
* 获取文件列表
*/
export function getWPS365FileList(params) {
return request({
url: '/jarvis/wps365/files',
method: 'get',
params
})
}
/**
* 获取文件信息
*/
export function getWPS365FileInfo(userId, fileToken) {
return request({
url: '/jarvis/wps365/fileInfo',
method: 'get',
params: { userId, fileToken }
})
}
// ==================== 工作表相关 ====================
/**
* 获取工作表列表
*/
export function getWPS365SheetList(userId, fileToken) {
return request({
url: '/jarvis/wps365/sheets',
method: 'get',
params: { userId, fileToken }
})
}
/**
* 创建数据表
*/
export function createWPS365Sheet(data) {
return request({
url: '/jarvis/wps365/createSheet',
method: 'post',
data
})
}
// ==================== 单元格操作相关 ====================
/**
* 读取单元格数据
*/
export function readWPS365Cells(params) {
return request({
url: '/jarvis/wps365/readCells',
method: 'get',
params
})
}
/**
* 更新单元格数据
*/
export function updateWPS365Cells(data) {
return request({
url: '/jarvis/wps365/updateCells',
method: 'post',
data
})
}
/**
* 批量更新单元格数据
*/
export function batchUpdateWPS365Cells(data) {
return request({
url: '/jarvis/wps365/batchUpdateCells',
method: 'post',
data
})
}
// ==================== AirSheet相关 ====================
/**
* 读取AirSheet工作表数据
* @param {Object} params - {userId, fileId, worksheetId(可选默认0), range(可选)}
*/
export function readWPS365AirSheetCells(params) {
return request({
url: '/jarvis/wps365/readAirSheetCells',
method: 'get',
params
})
}
/**
* 更新AirSheet工作表数据
* @param {Object} data - {userId, fileId, worksheetId(可选默认0), range, values}
*/
export function updateWPS365AirSheetCells(data) {
return request({
url: '/jarvis/wps365/updateAirSheetCells',
method: 'post',
data
})
}

View File

@@ -7,3 +7,11 @@ export function getServer() {
method: 'get'
})
}
// 获取服务健康度检测
export function getHealth() {
return request({
url: '/monitor/server/health',
method: 'get'
})
}

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,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

@@ -8,4 +8,23 @@ export function executeInstruction(data) {
})
}
export function executeInstructionWithForce(data) {
return request({
url: '/jarvis/instruction/execute',
method: 'post',
data: {
...data,
forceGenerate: true
}
})
}
export function getHistory(type, limit) {
return request({
url: '/jarvis/instruction/history',
method: 'get',
params: { type, limit }
})
}

View File

@@ -17,6 +17,15 @@ export function getJDOrder(id) {
})
}
// 更新JD订单
export function updateJDOrder(data) {
return request({
url: '/system/jdorder',
method: 'put',
data: data
})
}
// 一键转链
export function generatePromotionContent(data) {
return request({
@@ -121,6 +130,24 @@ export function transferWithGift(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({
@@ -129,3 +156,48 @@ export function exportJDOrders(query) {
params: query
})
}
// 删除JD订单支持批量ids为逗号分隔或数组
export function delJDOrder(ids) {
// 兼容数组或字符串
const idPath = Array.isArray(ids) ? ids.join(',') : ids
return request({
url: `/system/jdorder/${idPath}`,
method: 'delete'
})
}
1
// 手动获取物流信息(用于调试)
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'
})
}
// 生成录单格式文本Excel可粘贴格式
export function generateExcelText(query) {
return request({
url: '/system/jdorder/generateExcelText',
method: 'get',
params: query
})
}

View File

@@ -1,87 +0,0 @@
// 自定义侧边栏样式 - 适配蓝色渐变背景
.sidebar-container {
// 覆盖Element UI菜单的默认样式
.el-menu {
background: transparent !important;
border: none !important;
.el-menu-item, .el-submenu__title {
color: #ffffff !important;
background: transparent !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: #ffffff !important;
}
&.is-active {
background-color: rgba(255, 255, 255, 0.2) !important;
color: #ffffff !important;
border-right: 3px solid #ffffff !important;
}
}
.el-submenu {
.el-menu {
background-color: rgba(255, 255, 255, 0.05) !important;
.el-menu-item {
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
&.is-active {
background-color: rgba(255, 255, 255, 0.15) !important;
color: #ffffff !important;
}
}
}
}
}
// 图标颜色
.svg-icon {
color: #ffffff !important;
}
// 文字颜色
span {
color: #ffffff !important;
}
// 激活状态的子菜单标题
.is-active > .el-submenu__title {
color: #ffffff !important;
}
// 子菜单展开时的样式
.el-submenu.is-opened > .el-submenu__title {
color: #ffffff !important;
}
}
// 弹出菜单样式
.el-menu--popup {
background: linear-gradient(135deg, #3aa4ef 0%, #0067e2 100%) !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
.el-menu-item {
color: #ffffff !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
&.is-active {
background-color: rgba(255, 255, 255, 0.2) !important;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.sidebar-container {
border-radius: 0 !important;
}
}

View File

@@ -90,3 +90,447 @@
.el-submenu__icon-arrow {
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

@@ -3,8 +3,8 @@
@import './transition.scss';
@import './element-ui.scss';
@import './sidebar.scss';
@import './custom-sidebar.scss';
@import './btn.scss';
@import './mobile.scss';
body {
height: 100%;
@@ -123,6 +123,10 @@ aside {
//main-container全局样式
.app-container {
padding: 20px;
@media (max-width: 768px) {
padding: 10px;
}
}
.components-container {
@@ -177,3 +181,214 @@ aside {
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 {
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 {

View File

@@ -12,21 +12,42 @@
}
.sidebar-container {
-webkit-transition: width .28s;
transition: width 0.28s;
-webkit-transition: width .28s ease-in-out;
transition: width 0.28s ease-in-out, box-shadow 0.3s ease;
width: $base-sidebar-width !important;
background: $base-menu-background;
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
height: 100vh;
position: fixed;
font-size: 0px;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
border-radius: 0 24px 0 0;
-webkit-box-shadow: 2px 0 6px rgba(0,21,41,.35);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
border-radius: 0 20px 20px 0;
-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
.horizontal-collapse-transition {
@@ -69,43 +90,132 @@
border: none;
height: 100%;
width: 100% !important;
font-size: 15px; // 增加菜单字体大小
font-size: 14px;
background: transparent !important;
padding: 10px 0;
}
.el-menu-item, .el-submenu__title {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
font-size: 15px; // 确保菜单项字体大小一致
font-size: 14px !important;
font-weight: 500;
color: rgba(33, 33, 33, 0.85) !important;
background: transparent !important;
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;
}
// menu hover
.submenu-title-noDropdown,
&:hover {
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;
}
}
}
// 子菜单样式优化
.el-submenu {
.el-submenu__title {
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
}
& .theme-dark .is-active > .el-submenu__title {
color: $base-menu-color-active !important;
}
& .nest-menu .el-submenu>.el-submenu__title,
& .el-submenu .el-menu-item {
min-width: $base-sidebar-width !important;
border-radius: 12px;
margin: 4px 12px;
padding: 0 16px !important;
height: 48px;
line-height: 48px;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
background: rgba(25, 118, 210, 0.1) !important;
color: #1976d2 !important;
transform: translateX(4px);
}
}
& .theme-dark .nest-menu .el-submenu>.el-submenu__title,
& .theme-dark .el-submenu .el-menu-item {
background-color: $base-sub-menu-background !important;
.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-color: $base-sub-menu-hover !important;
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;
}
}
}
}
}
// 图标样式优化
.svg-icon {
color: rgba(33, 33, 33, 0.7) !important;
transition: all 0.3s ease;
margin-right: 12px;
&:hover {
color: #1976d2 !important;
transform: scale(1.1);
}
}
}
@@ -130,6 +240,14 @@
margin-left: 20px;
}
}
span {
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
}
.el-submenu {
@@ -142,6 +260,13 @@
margin-left: 20px;
}
span {
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
}
}
@@ -149,14 +274,24 @@
.el-submenu {
&>.el-submenu__title {
&>span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
}
}
.el-menu-item {
span {
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
}
}
}
@@ -171,8 +306,24 @@
}
.sidebar-container {
transition: transform .28s;
transition: transform .28s ease-in-out;
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 {
@@ -180,6 +331,38 @@
pointer-events: none;
transition-duration: 0.3s;
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;
}
}
}
@@ -191,9 +374,55 @@
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);
}
// when menu collapsed
&.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);
}
}
}
// 收起状态下的样式
.el-menu--vertical {
&>.el-menu {
.svg-icon {
@@ -204,27 +433,7 @@
.nest-menu .el-submenu>.el-submenu__title,
.el-menu-item {
&:hover {
// you can use $subMenuHover
background-color: rgba(58, 164, 239, 0.1) !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;
background: rgba(255, 255, 255, 0.15) !important;
}
}
}

View File

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

View File

@@ -0,0 +1,134 @@
<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);
}
/* 表格区域 - 可滚动 */
.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;
}
/* 表格区域滚动条样式 */
.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-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,311 @@
<template>
<div class="mobile-bottom-nav" :class="{ 'scrollable': navItems.length > 5 }" v-if="isMobile && show">
<div
v-for="item in navItems"
:key="item.path"
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
}
},
data() {
return {
navItemsCache: null
}
},
computed: {
...mapGetters(['device', 'sidebarRouters']),
isMobile() {
return this.device === 'mobile' || window.innerWidth < 768
},
navItems() {
// 如果提供了自定义items直接使用
if (this.items && this.items.length > 0) {
return this.items
}
// 使用缓存,避免重复计算
if (this.navItemsCache) {
return this.navItemsCache
}
// 从侧边栏路由中获取可用的路由
const routes = this.sidebarRouters || []
// 扁平化路由,获取所有叶子节点路由
const flattenRoutes = (routes, parentPath = '') => {
let result = []
if (!routes || !Array.isArray(routes)) {
return result
}
routes.forEach(route => {
if (route.hidden) return
// 处理路径 - 确保路径正确
let fullPath = route.path || ''
if (parentPath) {
if (fullPath.startsWith('/')) {
fullPath = fullPath
} else {
// 合并路径
const basePath = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath
fullPath = `${basePath}/${fullPath}`.replace(/\/+/g, '/')
}
}
// 确保路径以/开头
if (fullPath && !fullPath.startsWith('/')) {
fullPath = '/' + fullPath
}
// 如果有子路由,递归处理
if (route.children && route.children.length > 0) {
result = result.concat(flattenRoutes(route.children, fullPath))
} else {
// 叶子节点路由且有meta信息
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: route
})
}
}
})
return result
}
const flatRoutes = 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) {
this.navItemsCache = mainRoutes
return mainRoutes
}
// 如果没有找到路由,返回默认导航
const defaultRoutes = [
{
path: '/sloworder/index',
label: '首页',
icon: 'el-icon-s-home'
}
]
this.navItemsCache = defaultRoutes
return defaultRoutes
}
},
watch: {
sidebarRouters: {
handler() {
// 路由变化时清除缓存
this.navItemsCache = null
},
deep: true
}
},
mounted() {
// 等待路由加载完成
this.$nextTick(() => {
// 延迟一下,确保路由已经加载
setTimeout(() => {
this.navItemsCache = null
this.$forceUpdate()
}, 500)
})
},
methods: {
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: flex-start;
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;
.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"
:page-size.sync="pageSize"
:layout="layout"
:page-sizes="[15, 50, 100, 200,1]"
:page-sizes="pageSizes"
:pager-count="pagerCount"
:total="total"
v-bind="$attrs"
@@ -31,18 +31,18 @@ export default {
},
limit: {
type: Number,
default: 15
default: 50
},
pageSizes: {
type: Array,
default() {
return [15, 50, 100, 200,1]
return [50, 100, 200, 500, 1000]
}
},
// 移动端页码按钮的数量端默认值5
pagerCount: {
type: Number,
default: document.body.clientWidth < 992 ? 5 : 7
default: 7
},
layout: {
type: String,
@@ -63,8 +63,19 @@ export default {
},
data() {
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: {
currentPage: {
get() {
@@ -84,6 +95,13 @@ export default {
}
},
methods: {
handleResize() {
const wasMobile = this.isMobile
this.isMobile = window.innerWidth < 768
if (wasMobile !== this.isMobile) {
this.$forceUpdate()
}
},
handleSizeChange(val) {
if (this.currentPage * val > this.total) {
this.currentPage = 1
@@ -106,6 +124,33 @@ export default {
<style scoped>
.pagination-container {
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 {
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

@@ -376,6 +376,9 @@ export default {
else { this.$modal.msgError(res.msg || '加载属性失败'); }
} catch(e) { this.$modal.msgError('加载属性失败'); }
},
onPvChange() {
// 属性值变更时的回调(可用于调试或联动逻辑)
},
submitPublish() {
this.$refs.publishForm.validate(valid => {
if (!valid) return;
@@ -389,10 +392,26 @@ export default {
try { channelPv = JSON.parse(f.channelPvJson); }
catch(e) { this.$modal.msgError('属性JSON格式不正确'); return; }
} else if (this.selectedPv && Object.keys(this.selectedPv).length) {
channelPv = Object.keys(this.selectedPv).map(pid => ({
property_id: isNaN(Number(pid)) ? pid : Number(pid),
value_id: this.selectedPv[pid]
}));
// 从 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,

View File

@@ -151,4 +151,39 @@ export default {
background-color: #ccc;
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>

View File

@@ -51,24 +51,50 @@ export default {
width: 100%;
position: relative;
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) {
padding-bottom: 36px;
@media (max-width: 768px) {
padding-bottom: 30px;
}
}
.fixed-header + .app-main {
padding-top: 50px;
@media (max-width: 768px) {
padding-top: 48px;
}
}
.hasTagsView {
.app-main {
/* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 84px);
@media (max-width: 768px) {
min-height: calc(100vh - 48px);
}
}
.fixed-header + .app-main {
padding-top: 84px;
@media (max-width: 768px) {
padding-top: 48px;
}
}
}
</style>

View File

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

View File

@@ -1,14 +1,14 @@
<template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}">
<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">
<div class="logo-icon">
<i class="el-icon-shopping-cart-2"></i>
<i class="el-icon-chat-line-round"></i>
</div>
</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">
<div class="logo-icon">
<i class="el-icon-shopping-cart-2"></i>
<i class="el-icon-chat-line-round"></i>
</div>
<h1 class="sidebar-title">{{ title }}</h1>
</router-link>
@@ -60,10 +60,17 @@ export default {
width: 100%;
height: 60px;
line-height: 60px;
background: linear-gradient(90deg, #3aa4ef 0%, #0067e2 100%);
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
text-align: center;
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 {
height: 100%;
@@ -73,42 +80,64 @@ export default {
justify-content: center;
padding: 0 20px;
@media (max-width: 768px) {
padding: 0 15px;
}
& .logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
background: rgba(255, 255, 255, 0.15);
border: 2px solid rgba(255, 255, 255, 0.3);
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: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
color: #1976d2;
text-shadow: none;
@media (max-width: 768px) {
font-size: 18px;
}
}
&:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
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(0, 0, 0, 0.15);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2);
}
&:active {
transform: scale(0.95);
}
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
color: #1976d2;
font-weight: 600;
font-size: 16px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
text-shadow: none;
@media (max-width: 768px) {
font-size: 14px;
}
}
}

View File

@@ -1,10 +1,10 @@
<template>
<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-menu
:default-active="activeMenu"
:collapse="isCollapse"
:collapse="false"
:background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground"
:text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
:unique-opened="true"
@@ -48,9 +48,6 @@ export default {
},
variables() {
return variables
},
isCollapse() {
return !this.sidebar.opened
}
}
}

View File

@@ -244,6 +244,14 @@ export default {
background: #fff;
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);
// 移动端优化
@media (max-width: 768px) {
height: 0;
overflow: hidden;
display: none; // 移动端隐藏标签页
}
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
@@ -258,11 +266,30 @@ export default {
font-size: 12px;
margin-left: 5px;
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 {
margin-left: 15px;
@media (max-width: 768px) {
margin-left: 10px;
}
}
&:last-of-type {
margin-right: 15px;
@media (max-width: 768px) {
margin-right: 10px;
}
}
&.active {
background-color: #42b983;
@@ -298,13 +325,39 @@ export default {
font-weight: 400;
color: #333;
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 {
margin: 0;
padding: 7px 16px;
cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
@media (max-width: 768px) {
padding: 10px 16px;
min-height: 44px;
}
&:hover {
background: #eee;
}
&:active {
background: #ddd;
}
i {
margin-right: 8px;
font-size: 14px;
}
}
}
}

View File

@@ -1,22 +1,28 @@
<template>
<div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
<sidebar v-if="!sidebar.hide" class="sidebar-container"/>
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">
<sidebar v-if="!sidebar.hide && device !== 'mobile'" class="sidebar-container"/>
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide, 'mobile-layout': device === 'mobile'}" class="main-container">
<div :class="{'fixed-header':fixedHeader}">
<navbar @setLayout="setLayout"/>
<tags-view v-if="needTagsView"/>
<tags-view v-if="needTagsView && device !== 'mobile'"/>
</div>
<app-main/>
<settings ref="settingRef"/>
</div>
<!-- 移动端底部导航 -->
<mobile-bottom-nav
v-if="device === 'mobile'"
:items="mobileNavItems"
/>
</div>
</template>
<script>
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import MobileBottomNav from '@/components/MobileBottomNav'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'
import { mapState, mapGetters } from 'vuex'
import variables from '@/assets/styles/variables.scss'
export default {
@@ -26,7 +32,8 @@ export default {
Navbar,
Settings,
Sidebar,
TagsView
TagsView,
MobileBottomNav
},
mixins: [ResizeMixin],
computed: {
@@ -38,10 +45,15 @@ export default {
needTagsView: state => state.settings.tagsView,
fixedHeader: state => state.settings.fixedHeader
}),
...mapGetters(['sidebarRouters']),
mobileNavItems() {
// 如果返回空数组,组件会使用默认逻辑从路由中自动获取所有可用路由
return []
},
classObj() {
return {
hideSidebar: !this.sidebar.opened,
openSidebar: this.sidebar.opened,
hideSidebar: false, // 侧边栏始终展开
openSidebar: true, // 侧边栏始终展开
withoutAnimation: this.sidebar.withoutAnimation,
mobile: this.device === 'mobile'
}
@@ -75,6 +87,18 @@ export default {
position: fixed;
top: 0;
}
@media (max-width: 768px) {
&.mobile {
height: auto;
min-height: 100vh;
position: relative;
&.openSidebar {
position: relative;
}
}
}
}
.drawer-bg {
@@ -107,4 +131,43 @@ export default {
.mobile .fixed-header {
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>

View File

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

View File

@@ -16,6 +16,7 @@ import { download } from '@/utils/request'
import './assets/icons' // icon
import './permission' // permission control
import { initMobile } from '@/utils/mobile' // 移动端初始化
import { getDicts } from "@/api/system/dict/data"
import { getConfigKey } from "@/api/system/config"
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 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
@@ -55,6 +61,11 @@ Vue.component('Editor', Editor)
Vue.component('FileUpload', FileUpload)
Vue.component('ImageUpload', ImageUpload)
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(plugins)
@@ -75,6 +86,9 @@ Vue.use(Element, {
Vue.config.productionTip = false
// 初始化移动端优化
initMobile()
new Vue({
el: '#app',
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 })
const whiteList = ['/login', '/register','/public/comment']
const whiteList = ['/login', '/register', '/public/home', '/tools/comment-gen', '/tools/order-search', '/public/order-submit', '/wps365-callback', '/tendoc-callback']
const isWhiteList = (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)
/* has token*/
if (to.path === '/login') {
next({ path: '/' })
next({ path: '/user/profile' })
NProgress.done()
} else if (isWhiteList(to.path)) {
next()
@@ -39,7 +39,7 @@ router.beforeEach((to, from, next) => {
}).catch(err => {
store.dispatch('LogOut').then(() => {
Message.error(err)
next({ path: '/' })
next({ path: '/user/profile' })
})
})
} else {

View File

@@ -28,7 +28,7 @@ import Layout from '@/layout'
}
*/
// 公共路由
// 公共路由(无需权限即可访问)
export const constantRoutes = [
{
path: '/redirect',
@@ -62,17 +62,8 @@ export const constantRoutes = [
hidden: true
},
{
path: '',
component: Layout,
redirect: 'order/list',
children: [
{
path: 'order/list',
component: () => import('@/views/system/orderrows/index'),
name: 'OrderList',
meta: { title: '京粉订单', icon: 'order', affix: true }
}
]
path: '/',
redirect: '/sloworder/index'
},
{
path: '/user',
@@ -88,70 +79,31 @@ export const constantRoutes = [
}
]
},
// 公共工具首页
{
path: '/order',
component: Layout,
redirect: 'list',
name: 'Order',
meta: { title: '京粉订单', icon: 'money' },
children: [
{
path: 'list',
component: () => import('@/views/system/orderrows/index'),
name: 'OrderList',
meta: { title: '订单列表', icon: 'list' }
path: '/public/home',
component: () => import('@/views/public/PublicHome'),
hidden: true
},
// 评论生成工具(内部使用,不易被发现的路径)
{
path: 'statistics',
component: () => import('@/views/system/orderrows/statistics'),
name: 'OrderStatistics',
meta: { title: '订单统计', icon: 'chart' }
},
{
path: 'settings',
component: () => import('@/views/system/orderrows/settings'),
name: 'OrderSettings',
meta: { title: '订单设置', icon: 'setting' }
}
]
},
// 公开评论独立页(不使用 Layout无侧边栏
{
path: '/public/comment',
path: '/tools/comment-gen',
component: () => import('@/views/public/CommentGenerator'),
hidden: true
},
// 订单搜索工具(内部使用,不易被发现的路径)
{
path: '/jdorder',
component: Layout,
redirect: 'noredirect',
name: 'Jdorder',
meta: { title: '一键转链', icon: 'link' },
children: [
{
path: 'index',
component: () => import('@/views/system/jdorder/index'),
name: 'JdorderIndex',
meta: { title: '转链工具', icon: 'tool' }
}
]
path: '/tools/order-search',
component: () => import('@/views/public/OrderSearch'),
hidden: true
},
// 公开订单提交页(不使用 Layout无侧边栏
{
path: '/jd-instruction',
component: Layout,
redirect: 'noredirect',
name: 'JdInstruction',
meta: { title: '京东指令台', icon: 'guide' },
children: [
{
path: 'index',
component: () => import('@/views/system/jd-instruction/index'),
name: 'JdInstructionIndex',
meta: { title: '指令执行', icon: 'form' }
}
]
path: '/public/order-submit',
component: () => import('@/views/public/order-submit/index'),
hidden: true
},
// 慢单管理(移到公共路由,无需权限)
{
path: '/sloworder',
component: Layout,
@@ -166,13 +118,84 @@ export const constantRoutes = [
meta: { title: '下好的慢单', icon: 'list' }
}
]
}
]
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
// 京粉订单管理
{
path: '/order',
component: Layout,
redirect: 'list',
name: 'Order',
meta: { title: '京粉订单', icon: 'money' },
permissions: ['jdorder:order:list'],
children: [
{
path: 'list',
component: () => import('@/views/system/orderrows/index'),
name: 'OrderList',
meta: { title: '订单列表', icon: 'list' }
},
{
path: 'statistics',
component: () => import('@/views/system/orderrows/statistics'),
name: 'OrderStatistics',
meta: { title: '订单统计', icon: 'chart' },
permissions: ['jdorder:order:statistics']
},
{
path: 'settings',
component: () => import('@/views/system/orderrows/settings'),
name: 'OrderSettings',
meta: { title: '订单设置', icon: 'setting' },
permissions: ['jdorder:order:settings']
}
]
},
// 一键转链工具
{
path: '/jdorder',
component: Layout,
redirect: 'noredirect',
name: 'Jdorder',
meta: { title: '一键转链', icon: 'link' },
permissions: ['jdorder:convert:list'],
children: [
{
path: 'index',
component: () => import('@/views/system/jdorder/index'),
name: 'JdorderIndex',
meta: { title: '转链工具', icon: 'tool' }
}
]
},
// 京东指令台
{
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: '/favorite',
component: Layout,
redirect: 'noredirect',
name: 'Favorite',
meta: { title: '常用商品', icon: 'star' },
permissions: ['jdorder:favorite:list'],
children: [
{
path: 'index',
@@ -182,12 +205,14 @@ export const constantRoutes = [
}
]
},
// 线报消息管理
{
path: '/message',
component: Layout,
redirect: 'noredirect',
name: 'Message',
meta: { title: '线报消息', icon: 'message' },
permissions: ['jdorder:message:list'],
children: [
{
path: 'index',
@@ -197,12 +222,46 @@ export const constantRoutes = [
}
]
},
// 批量发品
{
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: 'noredirect',
name: 'DocSync',
meta: { title: '文档同步配置', icon: 'document' },
children: [
{
path: 'index',
component: () => import('@/views/jarvis/docSync/index'),
name: 'DocSyncIndex',
meta: { title: '文档同步配置', icon: 'document' }
}
]
},
// 线报群管理
{
path: '/xbgroup',
component: Layout,
redirect: 'noredirect',
name: 'Xbgroup',
meta: { title: '线报群管理', icon: 'peoples' },
permissions: ['jdorder:xbgroup:list'],
children: [
{
path: 'index',
@@ -212,12 +271,31 @@ export const constantRoutes = [
}
]
},
// 礼金管理
{
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',
component: Layout,
redirect: 'noredirect',
name: 'System',
meta: { title: '系统管理', icon: 'system' },
permissions: ['system:admin:list'],
children: [
{
path: 'superadmin',
@@ -227,10 +305,7 @@ export const constantRoutes = [
}
]
},
]
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
// 原有的系统路由
{
path: '/system/user-auth',
component: Layout,

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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,733 @@
<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">
<template v-if="config.currentProgress <= (form.startRow + 49)">
{{ form.startRow }}
</template>
<template v-else-if="config.currentProgress > (form.startRow + 100)">
{{ config.currentProgress - 100 }}
</template>
<template v-else>
{{ form.startRow }}
</template>
</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,563 @@
<template>
<div class="wps365-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 label="有效期" v-if="tokenInfo.expiresIn">
{{ 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;">
<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="例如VbHZwButmh从WPS365在线表格URL中获取"
clearable
>
<el-button
slot="append"
icon="el-icon-search"
@click="handleTestRead"
:disabled="!form.fileId || !isAuthorized"
>
测试读取
</el-button>
</el-input>
</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-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订单将自动写入WPS365在线表格' : '请完成配置') }}</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>文件ID从WPS365在线表格URL中获取</span>
</div>
<div class="help-item">
<i class="el-icon-check"></i>
<span>点击"测试读取"验证文件ID是否正确</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>
</div>
</template>
<script>
import {
getWPS365AuthUrl,
getWPS365TokenStatus,
readWPS365AirSheetCells,
updateWPS365AirSheetCells
} from '@/api/jarvis/wps365'
export default {
name: 'WPS365Config',
data() {
return {
isAuthorized: false,
userId: null,
tokenInfo: null,
config: {
isConfigured: false,
hint: ''
},
form: {
fileId: '',
headerRow: 2,
startRow: 3
},
rules: {
fileId: [
{ 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' }
]
},
saveLoading: false,
testLoading: false,
clearLoading: false
}
},
created() {
this.checkAuthStatus()
this.loadConfig()
},
methods: {
/** 刷新配置 */
refresh() {
this.checkAuthStatus()
this.loadConfig()
},
/** 检查授权状态 */
async checkAuthStatus() {
try {
const response = await getWPS365TokenStatus(this.userId)
if (response.code === 200) {
this.isAuthorized = response.data.hasToken && response.data.isValid
this.tokenInfo = response.data
if (response.data.userId) {
this.userId = response.data.userId
}
}
} catch (error) {
console.error('检查授权状态失败', error)
}
},
/** 加载配置 */
async loadConfig() {
try {
// TODO: 从后端加载配置
// 暂时从localStorage读取
const savedConfig = localStorage.getItem('wps365_auto_write_config')
if (savedConfig) {
const config = JSON.parse(savedConfig)
this.form.fileId = config.fileId || ''
this.form.headerRow = config.headerRow || 2
this.form.startRow = config.startRow || 3
this.config.isConfigured = !!(config.fileId)
}
} catch (error) {
console.error('加载配置失败', error)
}
},
/** 处理授权 */
async handleAuthorize() {
try {
const response = await getWPS365AuthUrl()
if (response.code === 200) {
const width = 600
const height = 700
const left = (window.screen.width - width) / 2
const top = (window.screen.height - height) / 2
window.open(
response.data,
'WPS365授权',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
)
this.$message.success('授权页面已打开,请在新窗口中完成授权')
const messageHandler = (event) => {
if (event.data && event.data.type === 'wps365_oauth_callback') {
window.removeEventListener('message', messageHandler)
if (event.data.userId) {
this.userId = event.data.userId
}
setTimeout(() => {
this.checkAuthStatus()
this.$message.success('授权完成')
}, 500)
}
}
window.addEventListener('message', messageHandler)
setTimeout(() => {
this.checkAuthStatus()
}, 3000)
}
} catch (error) {
this.$message.error('获取授权URL失败' + (error.msg || error.message))
}
},
/** 刷新授权状态 */
async handleRefreshAuth() {
await this.checkAuthStatus()
this.$message.success('授权状态已刷新')
},
/** 测试读取 */
async handleTestRead() {
if (!this.isAuthorized || !this.userId) {
this.$message.warning('请先完成授权')
return
}
if (!this.form.fileId) {
this.$message.warning('请输入文件ID')
return
}
this.$message.info('正在测试读取...')
try {
const response = await readWPS365AirSheetCells({
userId: this.userId,
fileId: this.form.fileId,
worksheetId: '0', // AirSheet中worksheetId通常是整数0表示第一个工作表
range: 'A1:B5'
})
if (response.code === 200) {
this.$message.success('读取成功文件ID正确。')
console.log('读取结果:', response.data)
} else {
this.$message.warning('读取失败: ' + (response.msg || '未知错误'))
}
} catch (error) {
this.$message.error('读取失败: ' + (error.msg || error.message))
console.error('读取错误:', error)
}
},
/** 保存配置 */
async handleSave() {
this.$refs.form.validate(async (valid) => {
if (!valid) {
return false
}
if (!this.isAuthorized) {
this.$message.warning('请先完成授权')
return
}
this.saveLoading = true
try {
// TODO: 保存到后端
// 暂时保存到localStorage
const config = {
fileId: this.form.fileId,
headerRow: this.form.headerRow,
startRow: this.form.startRow
}
localStorage.setItem('wps365_auto_write_config', JSON.stringify(config))
this.config.isConfigured = true
this.config.hint = 'H-TF订单将自动写入WPS365在线表格'
this.$message.success('配置保存成功')
} catch (error) {
this.$message.error('保存配置失败: ' + (error.msg || error.message))
} finally {
this.saveLoading = false
}
})
},
/** 测试配置 */
async handleTest() {
if (!this.isAuthorized || !this.userId) {
this.$message.warning('请先完成授权')
return
}
this.$refs.form.validate(async (valid) => {
if (!valid) {
return false
}
this.testLoading = true
try {
// 测试读取
const readResponse = await readWPS365AirSheetCells({
userId: this.userId,
fileId: this.form.fileId,
worksheetId: '0', // AirSheet中worksheetId通常是整数0表示第一个工作表
range: 'A1:B5'
})
if (readResponse.code !== 200) {
this.$message.error('读取测试失败: ' + (readResponse.msg || '未知错误'))
return
}
// 测试写入(写入测试数据)
const testRange = `A${this.form.startRow}:B${this.form.startRow}`
const testValues = [['测试数据1', '测试数据2']]
const writeResponse = await updateWPS365AirSheetCells({
userId: this.userId,
fileId: this.form.fileId,
worksheetId: '0', // AirSheet中worksheetId通常是整数0表示第一个工作表
range: testRange,
values: testValues
})
if (writeResponse.code === 200) {
this.$message.success('配置测试成功!读写功能正常。')
} else {
this.$message.warning('写入测试失败: ' + (writeResponse.msg || '未知错误'))
}
} catch (error) {
this.$message.error('配置测试失败: ' + (error.msg || error.message))
console.error('测试错误:', error)
} finally {
this.testLoading = false
}
})
},
/** 清除配置 */
async handleClear() {
this.$confirm('确定要清除配置吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.clearLoading = true
try {
// TODO: 清除后端配置
localStorage.removeItem('wps365_auto_write_config')
this.form.fileId = ''
this.form.headerRow = 2
this.form.startRow = 3
this.config.isConfigured = false
this.config.hint = ''
this.$message.success('配置已清除')
} catch (error) {
this.$message.error('清除配置失败: ' + (error.msg || error.message))
} finally {
this.clearLoading = false
}
}).catch(() => {})
}
}
}
</script>
<style scoped>
.wps365-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;
}
.token-info {
margin-top: 15px;
}
.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;
}
.help-card-wrapper {
margin-bottom: 20px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.help-content {
padding: 10px 0;
}
.help-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 13px;
color: #606266;
}
.help-item i {
color: #67c23a;
}
</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="WPS365" name="wps365">
<span slot="label">
<i class="el-icon-document-copy"></i> WPS365
</span>
<WPS365Config ref="wps365Config" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script>
import TencentDocConfig from './components/TencentDocConfig'
import WPS365Config from './components/WPS365Config'
export default {
name: 'DocSync',
components: {
TencentDocConfig,
WPS365Config
},
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 === 'wps365' && this.$refs.wps365Config) {
this.$refs.wps365Config.refresh()
}
})
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
::v-deep .el-tabs__header {
margin-bottom: 20px;
}
</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,279 @@
<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" 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>
<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
};
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,453 @@
<template>
<div class="app-container">
<el-card>
<div slot="header" class="clearfix">
<span>WPS365 在线表格管理</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>请先完成WPS365授权才能使用文档编辑功能</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>
<el-button
type="danger"
size="small"
style="margin-left: 10px"
@click="handleRefreshToken">
刷新Token
</el-button>
</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="用户ID">{{ userInfo.user_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ userInfo.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ userInfo.email || '-' }}</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">
加载文件
</el-button>
</div>
<el-table
v-loading="fileListLoading"
:data="fileList"
border
style="width: 100%">
<el-table-column prop="file_name" label="文件名" width="200" />
<el-table-column prop="file_token" label="文件Token" width="250" />
<el-table-column prop="file_type" label="类型" width="100" />
<el-table-column label="操作" width="200">
<template slot-scope="scope">
<el-button
type="primary"
size="mini"
@click="handleViewFile(scope.row)">
查看
</el-button>
<el-button
type="success"
size="mini"
@click="handleEditFile(scope.row)">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="fileTotal > 0"
:total="fileTotal"
:page.sync="queryParams.page"
:limit.sync="queryParams.pageSize"
@pagination="handleLoadFiles"
/>
</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, index) in sheetList"
:key="index"
:label="sheet.name"
:value="index">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="单元格范围">
<el-input
v-model="cellRange"
placeholder="例如A1:B10"
style="width: 200px">
</el-input>
</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">
</el-input>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
</el-card>
</div>
</template>
<script>
import {
getWPS365AuthUrl,
getWPS365TokenStatus,
getWPS365UserInfo,
getWPS365FileList,
getWPS365SheetList,
readWPS365Cells,
updateWPS365Cells,
refreshWPS365Token
} from '@/api/jarvis/wps365'
export default {
name: 'WPS365',
data() {
return {
loading: false,
isAuthorized: false,
userInfo: null,
userId: '', // 这里应该从登录用户获取,暂时使用配置
// 文件列表
fileList: [],
fileListLoading: false,
fileTotal: 0,
queryParams: {
page: 1,
pageSize: 20
},
// 编辑对话框
editDialogVisible: false,
currentFile: null,
currentSheetIdx: 0,
sheetList: [],
sheetData: [],
sheetDataLoading: false,
cellRange: 'A1:Z100',
sheetColumns: []
}
},
created() {
// 初始化时检查授权状态
// TODO: 从当前登录用户获取userId
this.userId = 'default_user' // 临时值需要替换为实际用户ID
this.checkAuthStatus()
},
methods: {
/**
* 检查授权状态
*/
async checkAuthStatus() {
try {
const response = await getWPS365TokenStatus(this.userId)
if (response.code === 200) {
this.isAuthorized = response.data.hasToken && response.data.isValid
if (this.isAuthorized) {
this.loadUserInfo()
}
}
} catch (error) {
console.error('检查授权状态失败', error)
}
},
/**
* 加载用户信息
*/
async loadUserInfo() {
try {
const response = await getWPS365UserInfo(this.userId)
if (response.code === 200) {
this.userInfo = response.data
}
} catch (error) {
console.error('加载用户信息失败', error)
}
},
/**
* 处理授权
*/
async handleAuthorize() {
try {
const response = await getWPS365AuthUrl()
if (response.code === 200) {
// 在新窗口打开授权页面
window.open(response.data, '_blank')
this.$message.success('请在新窗口中完成授权,授权完成后刷新此页面')
}
} catch (error) {
this.$message.error('获取授权URL失败' + (error.msg || error.message))
}
},
/**
* 刷新Token
*/
async handleRefreshToken() {
try {
// TODO: 从存储中获取refreshToken
const response = await refreshWPS365Token({
refreshToken: '' // 需要从存储中获取
})
if (response.code === 200) {
this.$message.success('Token刷新成功')
this.checkAuthStatus()
}
} catch (error) {
this.$message.error('刷新Token失败' + (error.msg || error.message))
}
},
/**
* 刷新
*/
handleRefresh() {
this.checkAuthStatus()
if (this.isAuthorized) {
this.handleLoadFiles()
}
},
/**
* 加载文件列表
*/
async handleLoadFiles() {
if (!this.isAuthorized) {
this.$message.warning('请先完成授权')
return
}
this.fileListLoading = true
try {
const response = await getWPS365FileList({
userId: this.userId,
page: this.queryParams.page,
pageSize: this.queryParams.pageSize
})
if (response.code === 200) {
this.fileList = response.data.files || []
this.fileTotal = response.data.total || 0
}
} catch (error) {
this.$message.error('加载文件列表失败:' + (error.msg || error.message))
} finally {
this.fileListLoading = false
}
},
/**
* 查看文件
*/
handleViewFile(file) {
this.$message.info('查看文件功能开发中')
},
/**
* 编辑文件
*/
async handleEditFile(file) {
this.currentFile = file
this.editDialogVisible = true
// 加载工作表列表
try {
const response = await getWPS365SheetList(this.userId, file.file_token)
if (response.code === 200) {
this.sheetList = response.data.sheets || []
if (this.sheetList.length > 0) {
this.currentSheetIdx = 0
this.handleLoadSheetData()
}
}
} catch (error) {
this.$message.error('加载工作表列表失败:' + (error.msg || error.message))
}
},
/**
* 加载工作表数据
*/
async handleLoadSheetData() {
if (!this.currentFile) {
return
}
this.sheetDataLoading = true
try {
const response = await readWPS365Cells({
userId: this.userId,
fileToken: this.currentFile.file_token,
sheetIdx: this.currentSheetIdx,
range: this.cellRange
})
if (response.code === 200) {
const values = response.data.values || []
this.processSheetData(values)
}
} catch (error) {
this.$message.error('加载工作表数据失败:' + (error.msg || error.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.length))
this.sheetColumns = Array.from({ length: maxCols }, (_, i) => i)
// 转换为表格数据格式
this.sheetData = values.map(row => {
const rowData = {}
row.forEach((cell, index) => {
rowData['col' + index] = cell !== null && cell !== undefined ? String(cell) : ''
})
// 填充空列
for (let i = row.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 value = row['col' + colIndex]
return value !== undefined ? value : ''
})
})
try {
const response = await updateWPS365Cells({
userId: this.userId,
fileToken: this.currentFile.file_token,
sheetIdx: this.currentSheetIdx,
range: this.cellRange,
values: values
})
if (response.code === 200) {
this.$message.success('更新成功')
}
} catch (error) {
this.$message.error('更新失败:' + (error.msg || error.message))
}
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
</style>

View File

@@ -24,11 +24,12 @@
</el-input>
</el-form-item>
<el-form-item prop="code" v-if="captchaEnabled">
<div style="display: flex; gap: 10px; align-items: center;">
<el-input
v-model="loginForm.code"
auto-complete="off"
placeholder="验证码"
style="width: 63%"
style="flex: 1"
@keyup.enter.native="handleLogin"
>
<svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon" />
@@ -36,6 +37,7 @@
<div class="login-code">
<img :src="codeUrl" @click="getCode" class="login-code-img"/>
</div>
</div>
</el-form-item>
<el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
<el-form-item style="width:100%;">
@@ -73,8 +75,8 @@ export default {
title: process.env.VUE_APP_TITLE,
codeUrl: "",
loginForm: {
username: "admin",
password: "admin123",
username: "",
password: "",
rememberMe: false,
code: "",
uuid: ""
@@ -142,7 +144,17 @@ export default {
Cookies.remove('rememberMe')
}
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(() => {
this.loading = false
if (this.captchaEnabled) {
@@ -164,11 +176,26 @@ export default {
height: 100%;
background-image: url("../assets/images/login-background.jpg");
background-size: cover;
background-position: center;
padding: 20px;
// 移动端优化
@media (max-width: 768px) {
padding: 10px;
align-items: flex-start;
padding-top: 10vh;
}
}
.title {
margin: 0px auto 30px auto;
text-align: center;
color: #707070;
font-size: 24px;
@media (max-width: 768px) {
font-size: 20px;
margin: 0px auto 20px auto;
}
}
.login-form {
@@ -177,10 +204,26 @@ export default {
width: 400px;
padding: 25px 25px 5px 25px;
z-index: 1;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
// 移动端优化
@media (max-width: 768px) {
width: 100%;
max-width: 100%;
padding: 20px 15px 5px 15px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.el-input {
height: 38px;
input {
height: 38px;
font-size: 14px;
@media (max-width: 768px) {
font-size: 16px; // 防止iOS自动缩放
}
}
}
.input-icon {
@@ -188,6 +231,27 @@ export default {
width: 14px;
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 {
font-size: 13px;
@@ -198,9 +262,22 @@ export default {
width: 33%;
height: 38px;
float: right;
@media (max-width: 768px) {
width: 35%;
height: 44px;
}
img {
cursor: pointer;
vertical-align: middle;
width: 100%;
height: 100%;
object-fit: contain;
@media (max-width: 768px) {
height: 44px;
}
}
}
.el-login-footer {
@@ -214,8 +291,18 @@ export default {
font-family: Arial;
font-size: 12px;
letter-spacing: 1px;
@media (max-width: 768px) {
font-size: 11px;
height: 36px;
line-height: 36px;
}
}
.login-code-img {
height: 38px;
@media (max-width: 768px) {
height: 44px;
}
}
</style>

View File

@@ -171,23 +171,109 @@
</div>
</el-card>
</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">
<span><i class="el-icon-message"></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.wxSend && health.wxSend.healthy ? 'success' : 'danger'">
{{ 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}">
{{ health.wxSend && health.wxSend.message ? health.wxSend.message : '-' }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { getServer } from "@/api/monitor/server"
import { getServer, getHealth } from "@/api/monitor/server"
export default {
name: "Server",
data() {
return {
// 服务器信息
server: []
server: [],
// 健康度检测信息
health: {
logistics: null,
wxSend: null
}
}
},
created() {
this.getList()
this.getHealthInfo()
this.openLoading()
},
methods: {
@@ -198,6 +284,16 @@ export default {
this.$modal.closeLoading()
})
},
/** 查询健康度检测信息 */
getHealthInfo() {
getHealth().then(response => {
if (response.data) {
this.health = response.data
}
}).catch(error => {
console.error("获取健康度检测信息失败", error)
})
},
// 打开加载层
openLoading() {
this.$modal.loading("正在加载服务监控数据,请稍候!")

View File

@@ -1,33 +1,45 @@
<template>
<div class="mobile-container">
<div class="mobile-card">
<div class="mobile-header">
<h3>评论生成公开</h3>
</div>
<div class="mobile-form">
<!-- 访问统计区域 -->
<div class="form-section usage-statistics-section">
<div class="usage-stats-row">
<div class="usage-stat-item">
<div class="usage-stat-label">今天</div>
<div class="usage-stat-number">{{ usageStatistics.today || 0 }}</div>
</div>
<div class="usage-stat-item">
<div class="usage-stat-label">近7天</div>
<div class="usage-stat-number">{{ usageStatistics.last7Days || 0 }}</div>
</div>
<div class="usage-stat-item">
<div class="usage-stat-label">近30天</div>
<div class="usage-stat-number">{{ usageStatistics.last30Days || 0 }}</div>
</div>
<div class="usage-stat-item">
<div class="usage-stat-label">累计</div>
<div class="usage-stat-number">{{ usageStatistics.total || 0 }}</div>
</div>
</div>
</div>
<!-- 产品类型选择区域 -->
<div class="form-section">
<div class="form-label">型号/类型</div>
<div class="select-row">
<el-select
v-model="form.productType"
filterable
placeholder="请选择型号/类型"
class="mobile-select"
size="medium"
>
<el-option v-for="it in typeOptions" :key="it.name" :label="it.name" :value="it.name" />
</el-select>
<el-button
type="primary"
size="medium"
class="refresh-btn"
@click="loadTypes"
icon="el-icon-refresh"
>
刷新
</el-button>
<div class="word-sea">
<div v-if="Object.keys(groupedByLetter).length === 0" class="empty-hint">暂无数据</div>
<div v-else v-for="(items, ltr) in groupedByLetter" :key="ltr" class="group">
<div class="group-head">{{ ltr }}</div>
<div class="group-items">
<span
v-for="it in items"
:key="it.name"
class="item-tag"
:class="{ active: form.productType === it.name }"
@click="selectType(it)"
>{{ it.name }}</span>
</div>
</div>
</div>
</div>
@@ -118,6 +130,10 @@
</span>
<span class="product-type">{{ statistics.productType }}</span>
</div>
<div v-if="statistics.lastCommentUpdateTime" class="update-time">
<i class="el-icon-time"></i>
更新日期{{ formatTime(statistics.lastCommentUpdateTime) }}
</div>
</div>
<div class="statistics-content">
@@ -158,14 +174,64 @@
</div>
</div>
</div>
<!-- 历史记录区域 -->
<div class="form-section history-section">
<div class="form-label">
<i class="el-icon-time"></i>
历史记录
<el-button
size="mini"
type="text"
icon="el-icon-refresh"
@click="loadHistory"
:loading="historyLoading"
style="margin-left: 8px;"
>
刷新
</el-button>
</div>
<div v-if="historyLoading" class="history-loading">
<i class="el-icon-loading"></i> 加载中...
</div>
<div v-else-if="historyList.length === 0" class="history-empty">
<i class="el-icon-document-remove"></i>
<p>暂无历史记录</p>
</div>
<div v-else class="history-list">
<div
v-for="(item, idx) in historyList"
:key="idx"
class="history-item"
>
<div class="history-item-header">
<span class="history-type">{{ item.productType || '-' }}</span>
<span class="history-time">{{ formatTime(item.createTime) }}</span>
</div>
<div class="history-item-info">
<span class="history-ip">IP: {{ item.ip || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 页尾导航 -->
<PublicFooterNav />
</div>
</template>
<script>
import { pinyin } from 'pinyin-pro'
import PublicFooterNav from '@/components/PublicFooterNav'
import { parseTime } from '@/utils/ruoyi'
export default {
name: 'CommentGeneratorPublic',
components: {
PublicFooterNav
},
data() {
return {
form: { productType: '' },
@@ -176,13 +242,53 @@ export default {
statistics: null,
lastGenerateTime: 0,
cooldownTime: 1000, // 5秒冷却时间
isButtonDisabled: false
isButtonDisabled: false,
currentIP: '',
usageStatistics: {
today: 0,
last7Days: 0,
last30Days: 0,
total: 0
},
historyList: [],
historyLoading: false
}
},
computed: {
pretty() {
try { return this.result ? JSON.stringify(this.result, null, 2) : '' } catch(e) { return '' }
},
groupedByLetter() {
const groups = {}
const items = Array.isArray(this.typeOptions) ? this.typeOptions.slice() : []
items.forEach(it => {
const ltr = this.getInitial(it)
if (!groups[ltr]) groups[ltr] = []
groups[ltr].push(it)
})
Object.keys(groups).forEach(k => {
groups[k].sort((a, b) => {
const an = (a.name || '').toString()
const bn = (b.name || '').toString()
return an.localeCompare(bn)
})
})
// 按字母顺序排序,确保显示顺序一致
const ordered = {}
const letters = Array.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
letters.concat('#').forEach(l => {
if (groups[l] && groups[l].length) {
ordered[l] = groups[l]
}
})
// 如果有其他字母不在A-Z范围内也添加进去
Object.keys(groups).forEach(k => {
if (!ordered[k] && groups[k].length) {
ordered[k] = groups[k]
}
})
return ordered
},
isGenerateButtonDisabled() {
// 如果正在加载、手动禁用、没有选择产品类型,或者在冷却时间内,则禁用按钮
return this.loading ||
@@ -198,6 +304,9 @@ export default {
},
mounted() {
this.loadTypes()
this.loadCurrentIP()
this.loadUsageStatistics()
this.loadHistory()
// 启动定时器更新冷却时间显示
this.cooldownTimer = setInterval(() => {
// 检查倒计时是否结束如果结束则清空lastGenerateTime
@@ -217,6 +326,55 @@ export default {
}
},
methods: {
async loadCurrentIP() {
try {
const res = await this.$axios({ url: '/public/comment/ip', method: 'get' })
if (res && (res.code === 200 || res.msg === '操作成功')) {
this.currentIP = res.data?.ip || res.data || '获取失败'
}
} catch(e) {
console.error('获取IP失败:', e)
this.currentIP = '获取失败'
}
},
async loadUsageStatistics() {
try {
const res = await this.$axios({ url: '/public/comment/usage-statistics', method: 'get' })
if (res && (res.code === 200 || res.msg === '操作成功')) {
this.usageStatistics = {
today: res.data?.today || 0,
last7Days: res.data?.last7Days || res.data?.last7days || 0,
last30Days: res.data?.last30Days || res.data?.last30days || 0,
total: res.data?.total || 0
}
}
} catch(e) {
console.error('获取使用统计失败:', e)
}
},
async loadHistory() {
this.historyLoading = true
try {
const res = await this.$axios({
url: '/public/comment/history',
method: 'get',
params: { pageNum: 1, pageSize: 20 }
})
if (res && (res.code === 200 || res.msg === '操作成功')) {
const list = res.data?.rows || res.data?.list || res.data || []
this.historyList = Array.isArray(list) ? list : []
}
} catch(e) {
console.error('获取历史记录失败:', e)
this.historyList = []
} finally {
this.historyLoading = false
}
},
formatTime(time) {
if (!time) return '-'
return parseTime(time, '{y}-{m}-{d} {h}:{i}:{s}')
},
async loadTypes() {
try {
const res = await this.$axios({ url: '/public/comment/types', method: 'get' })
@@ -227,6 +385,34 @@ export default {
}
} catch(e) {}
},
getInitial(it) {
const source = (it && (it.name || it.value) || '').toString().trim()
if (!source) return '#'
const firstChar = source[0]
const upperAscii = firstChar.toUpperCase()
if (upperAscii >= 'A' && upperAscii <= 'Z') return upperAscii
// 中文等非 ASCII 字符,取拼音首字母
try {
const letter = pinyin(firstChar, { toneType: 'none', type: 'array' })[0]
if (!letter || !letter.length) return '#'
const initial = letter[0].toUpperCase()
return (initial >= 'A' && initial <= 'Z') ? initial : '#'
} catch(e) {
return '#'
}
},
selectType(it) {
if (!it) return
// 如果选择的是同一个型号,不重复提交
if (this.form.productType === it.name) {
return
}
this.form.productType = it.name
// 自动提交请求获取评论
this.$nextTick(() => {
this.generate()
})
},
async generate() {
// 检查按钮是否被禁用
if (this.isGenerateButtonDisabled) {
@@ -278,6 +464,10 @@ export default {
// 解析统计信息
this.statistics = res.data && res.data.statistics ? res.data.statistics : null
// 刷新使用统计和历史记录
this.loadUsageStatistics()
this.loadHistory()
this.$message.success('评论生成成功')
} else {
this.$message.error(res && res.msg ? res.msg : '生成失败')
@@ -347,6 +537,7 @@ export default {
min-height: 100vh;
background: #f5f5f5;
padding: 16px;
padding-bottom: calc(80px + 16px); /* 为页尾导航留出空间 */
}
.mobile-card {
@@ -356,27 +547,14 @@ export default {
overflow: hidden;
}
/* 头部样式 */
.mobile-header {
background: linear-gradient(135deg, #409EFF 0%, #67C23A 100%);
color: #fff;
padding: 20px 16px;
text-align: center;
}
.mobile-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
/* 表单样式 */
.mobile-form {
padding: 20px 16px;
padding: 12px 16px;
}
.form-section {
margin-bottom: 24px;
margin-bottom: 16px;
}
.form-label {
@@ -384,6 +562,14 @@ export default {
font-weight: 600;
color: #303133;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.form-label i {
font-size: 16px;
color: #409eff;
}
/* 选择器行样式 */
@@ -398,10 +584,26 @@ export default {
min-width: 0;
}
.refresh-btn {
flex-shrink: 0;
border-radius: 8px;
/* 词海分组样式 */
.word-sea .group { margin-bottom: 12px; }
.word-sea .group-head { font-weight: 600; margin: 6px 0; color: #606266; }
.word-sea .group-items { display: flex; flex-wrap: wrap; }
.word-sea .item-tag {
display: inline-block;
padding: 8px 14px;
margin: 4px 8px 4px 0;
border: 1px solid #dcdfe6;
border-radius: 16px;
cursor: pointer;
color: #606266;
user-select: none;
transition: all .15s ease;
background: #fff;
font-size: 14px;
}
.word-sea .item-tag:hover { border-color: #409eff; color: #409eff; }
.word-sea .item-tag.active { background: #409eff; border-color: #409eff; color: #fff; }
.word-sea .empty-hint { color: #909399; }
/* 生成按钮样式 */
.generate-btn {
@@ -603,10 +805,6 @@ export default {
gap: 8px;
}
.refresh-btn {
width: 100%;
}
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 6px;
@@ -616,14 +814,18 @@ export default {
height: 80px;
}
.mobile-header h3 {
font-size: 16px;
}
.generate-btn {
height: 44px;
font-size: 15px;
}
/* 移动端型号选择按钮 */
.word-sea .item-tag {
padding: 6px 12px;
margin: 4px 6px 4px 0;
font-size: 13px;
border-radius: 14px;
}
}
@media (max-width: 360px) {
@@ -708,6 +910,21 @@ export default {
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.update-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #909399;
margin-top: 8px;
}
.update-time i {
font-size: 14px;
color: #409eff;
}
.source-tag {
@@ -884,6 +1101,155 @@ export default {
font-size: 11px;
}
}
/* 访问统计样式 */
.usage-statistics-section {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border: 1px solid #e1e8ff;
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 16px;
}
.usage-stats-row {
display: flex;
justify-content: space-around;
align-items: center;
gap: 4px;
}
.usage-stat-item {
flex: 1;
text-align: center;
background: #fff;
padding: 6px 4px;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.usage-stat-label {
font-size: 10px;
color: #909399;
font-weight: 500;
margin-bottom: 2px;
line-height: 1.2;
}
.usage-stat-number {
font-size: 16px;
font-weight: 700;
color: #409eff;
line-height: 1.2;
}
/* 历史记录样式 */
.history-section {
margin-top: 24px;
border-top: 2px solid #f0f0f0;
padding-top: 24px;
}
.history-loading,
.history-empty {
text-align: center;
padding: 40px 20px;
color: #909399;
}
.history-loading i,
.history-empty i {
font-size: 32px;
margin-bottom: 12px;
display: block;
color: #c0c4cc;
}
.history-empty p {
margin: 0;
font-size: 14px;
}
.history-list {
max-height: 400px;
overflow-y: auto;
}
.history-item {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
}
.history-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.history-type {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.history-time {
font-size: 12px;
color: #909399;
}
.history-item-info {
display: flex;
align-items: center;
gap: 12px;
}
.history-ip {
font-size: 12px;
color: #606266;
}
/* 响应式适配 */
@media (max-width: 480px) {
.usage-statistics-section {
padding: 6px 8px;
margin-bottom: 12px;
}
.usage-stats-row {
gap: 3px;
}
.usage-stat-item {
padding: 5px 3px;
}
.usage-stat-number {
font-size: 14px;
}
.usage-stat-label {
font-size: 9px;
}
.history-item-header {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
</style>

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,614 @@
<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="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="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="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="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>
</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
} 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: []
}
},
computed: {
...mapGetters(['device']),
isMobile() {
if (this.device === 'mobile') {
return true
}
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return true
}
return false
},
jdActionButtons() {
return [
{ key: 'export', label: '导出', type: 'success', icon: 'el-icon-download', handler: () => this.handleJdExport(), disabled: false }
]
},
tbActionButtons() {
return [
{ 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`)
},
// 淘宝评论相关
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`)
},
// 统计信息相关
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,720 @@
<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 || [];
});
},
/** 加载会员名列表 */
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;
}
this.download('jarvis/erpProduct/export', {
...this.queryParams
}, `erpProduct_${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

@@ -1,6 +1,19 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<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"
@@ -31,13 +44,25 @@
<el-option label="否" :value="0" />
</el-select>
</el-form-item>
<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>
<el-row :gutter="10" class="mb8">
<!-- 操作按钮区域移动端单独显示 -->
<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"
@@ -347,14 +372,38 @@
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 },
components: {
PublishDialog,
MobileSearchForm,
MobileButtonGroup
},
computed: {
...mapGetters(['favoriteProductRefreshKey'])
...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 {
@@ -628,4 +677,31 @@ export default {
.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

@@ -9,11 +9,21 @@
<el-form-item label="输入指令">
<el-input v-model="form.command" type="textarea" :rows="8" placeholder="例如:京今日统计 / 京昨日订单 / 慢搜关键词 / 录单20250101-20250107" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="run" :loading="loading">执行</el-button>
<el-button @click="fillMenu">菜单</el-button>
<el-button @click="clearAll">清空</el-button>
</el-form-item>
<div class="button-group button-group-primary">
<el-button type="success" size="medium" @click="run" :loading="loading">执行</el-button>
<el-button type="danger" size="medium" @click="clearAll">清空</el-button>
<el-button size="warning" @click="fillMan">慢单</el-button>
<el-button v-if="isMobile" size="primary" @click="fillTF">腾峰</el-button>
<el-button v-if="!isMobile" size="success" @click="fillSheng"></el-button>
</div>
<div class="button-group button-group-secondary">
<el-button v-if="!isMobile" size="primary" @click="fillTF">腾峰</el-button>
<el-button v-if="!isMobile" type="primary" size="medium" @click="fillFan"></el-button>
<el-button v-if="!isMobile" type="primary" size="medium" @click="fillWen"></el-button>
<el-button v-if="!isMobile" type="primary" size="medium" @click="fillHong">鸿</el-button>
<el-button v-if="!isMobile" type="primary" size="medium" @click="fillPDD">拼多多</el-button>
<el-button v-if="!isMobile" type="primary" size="medium" @click="fillPDDWen">拼多多-纹</el-button>
</div>
</el-form>
<el-divider>响应</el-divider>
@@ -21,24 +31,146 @@
<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;">
<div v-else class="response-container">
<!-- 上面完整消息 -->
<div class="response-section response-section-full">
<div class="response-header">
<span>完整消息</span>
<el-button size="mini" type="primary" @click="copyAll">复制全部</el-button>
</div>
<div class="response-content-full">
<el-input :value="fullMessage" type="textarea" :rows="15" readonly />
</div>
</div>
<!-- 下面独立消息列表 -->
<div class="response-section response-section-list">
<div class="response-header">
<span>消息列表 {{ resultList.length }} </span>
</div>
<div class="response-content-list">
<div class="message-list">
<div v-for="(msg, idx) in resultList" :key="idx" class="message-item">
<div class="message-item-header">
<span class="message-index"> {{ idx + 1 }} </span>
<el-button size="mini" type="success" icon="el-icon-document-copy" @click="copyOne(msg)">复制</el-button>
</div>
<div class="message-content">{{ msg }}</div>
</div>
</div>
</div>
</div>
</div>
<el-divider>历史消息记录</el-divider>
<div class="history-controls">
<span class="history-label">显示条数</span>
<el-select v-model="historyLimit" size="small" style="width: 120px;" @change="loadHistory">
<el-option label="10条" :value="10"></el-option>
<el-option label="20条" :value="20"></el-option>
<el-option label="50条" :value="50"></el-option>
<el-option label="100条" :value="100"></el-option>
<el-option label="200条" :value="200"></el-option>
<el-option label="500条" :value="500"></el-option>
<el-option label="1000条" :value="1000"></el-option>
</el-select>
</div>
<div class="history-container">
<div class="history-column">
<div class="history-header">
<span>历史请求最近 {{ historyLimit }} </span>
<el-button size="mini" type="primary" icon="el-icon-refresh" @click="loadHistory" :loading="historyLoading">刷新</el-button>
</div>
<div class="history-content">
<div v-if="requestHistory.length === 0" class="empty-history">
<el-empty description="暂无历史请求" :image-size="80" />
</div>
<div v-else class="history-list">
<div v-for="(item, idx) in requestHistory" :key="'req-' + idx" class="history-item">
<div class="history-item-header">
<div class="history-time">{{ extractTime(item) }}</div>
<el-button
size="medium"
icon="el-icon-document-copy"
type="text"
@click="copyHistoryItem(item)"
title="复制此条消息">
</el-button>
</div>
<div class="history-text">{{ extractMessage(item) }}</div>
</div>
</div>
</div>
</div>
<div class="history-column">
<div class="history-header">
<span>历史响应最近 {{ historyLimit }} </span>
</div>
<div class="history-content">
<div v-if="responseHistory.length === 0" class="empty-history">
<el-empty description="暂无历史响应" :image-size="80" />
</div>
<div v-else class="history-list">
<div v-for="(item, idx) in responseHistory" :key="'res-' + idx" class="history-item">
<div class="history-item-header">
<div class="history-time">{{ extractTime(item) }}</div>
<el-button
size="medium"
icon="el-icon-document-copy"
type="text"
@click="copyHistoryItem(item)"
title="复制此条消息">
</el-button>
</div>
<div class="history-text">{{ extractMessage(item) }}</div>
</div>
</div>
</div>
</div>
</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>
</div>
</template>
<script>
import { executeInstruction } from '@/api/system/instruction'
import { executeInstruction, getHistory, executeInstructionWithForce } from '@/api/system/instruction'
export default {
name: 'JdInstruction',
@@ -46,9 +178,40 @@ export default {
return {
form: { command: '' },
loading: false,
resultList: []
resultList: [],
requestHistory: [],
responseHistory: [],
historyLoading: false,
historyLimit: 50,
// 验证码相关
verifyDialogVisible: false,
verifyCode: '',
verifyInput: '',
verifyMessage: '',
verifyLoading: false,
pendingCommand: '' // 待执行的命令(验证通过后执行)
}
},
computed: {
// 生成完整消息,用三个空行分隔
fullMessage() {
if (!this.resultList || this.resultList.length === 0) return ''
return this.resultList.join('\n\n\n')
},
// 检测移动端
isMobile() {
if (this.$store?.getters?.device === 'mobile') {
return true
}
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return true
}
return false
}
},
mounted() {
this.loadHistory()
},
methods: {
copyOne(text) {
if (!text) return
@@ -56,7 +219,16 @@ export default {
},
copyAll() {
if (!this.resultList || this.resultList.length === 0) return
const text = this.resultList.join('\n\n')
const text = this.resultList.join('\n\n\n')
this.doCopy(text)
},
copyHistory(type) {
const list = type === 'response' ? this.responseHistory : this.requestHistory
if (!list || list.length === 0) {
this.$modal.msgWarning('暂无历史记录')
return
}
const text = list.map(item => this.extractMessage(item)).join('\n\n')
this.doCopy(text)
},
doCopy(text) {
@@ -89,6 +261,23 @@ export default {
if (Array.isArray(data)) this.resultList = data
else if (typeof data === 'string') this.resultList = data ? [data] : []
else this.resultList = []
// 调试:打印返回结果
console.log('返回结果:', this.resultList)
// 检查是否是地址重复或订单编号重复错误
if (this.checkAddressDuplicate(this.resultList)) {
console.log('检测到重复错误,准备显示验证码弹窗')
// 显示验证码弹窗
this.showVerifyDialog(cmd)
return
}
// 检查是否有以[炸弹]开头的消息
this.checkBombAlert(this.resultList)
// 执行成功后刷新历史记录
this.loadHistory()
} else {
this.$modal.msgError(res && res.msg ? res.msg : '执行失败')
}
@@ -97,21 +286,722 @@ export default {
this.$modal.msgError('执行失败,请稍后重试')
})
},
fillMenu() {
this.form.command = '京菜单'
// 检查是否是地址重复或订单编号重复错误
checkAddressDuplicate(resultList) {
if (!resultList || resultList.length === 0) {
console.log('结果列表为空')
return false
}
console.log('检查重复错误,结果列表:', resultList)
for (let i = 0; i < resultList.length; i++) {
const result = resultList[i]
console.log(`检查第${i}个结果:`, result, '类型:', typeof result)
if (typeof result === 'string') {
// 检查是否包含地址重复或订单编号重复错误码
const hasAddressDuplicate = result.includes('ERROR_CODE:ADDRESS_DUPLICATE')
const hasOrderNumberDuplicate = result.includes('ERROR_CODE:ORDER_NUMBER_DUPLICATE')
if (hasAddressDuplicate || hasOrderNumberDuplicate) {
console.log('检测到重复错误:', result, '地址重复:', hasAddressDuplicate, '订单编号重复:', hasOrderNumberDuplicate)
return true
}
}
}
console.log('未检测到重复错误')
return false
},
// 显示验证码弹窗
showVerifyDialog(command) {
// 生成四位随机数字验证码
this.verifyCode = String(Math.floor(1000 + Math.random() * 9000))
this.verifyInput = ''
// 根据错误类型设置提示信息
let hasOrderNumberDuplicate = false
let hasAddressDuplicate = false
if (this.resultList && this.resultList.length > 0) {
for (let i = 0; i < this.resultList.length; i++) {
const result = this.resultList[i]
if (typeof result === 'string') {
if (result.includes('ERROR_CODE:ORDER_NUMBER_DUPLICATE')) {
hasOrderNumberDuplicate = true
}
if (result.includes('ERROR_CODE:ADDRESS_DUPLICATE')) {
hasAddressDuplicate = true
}
}
}
}
if (hasOrderNumberDuplicate && hasAddressDuplicate) {
this.verifyMessage = '检测到订单编号和地址重复,请输入验证码以强制生成表单'
} else if (hasOrderNumberDuplicate) {
this.verifyMessage = '检测到订单编号重复,请输入验证码以强制生成表单'
} else {
this.verifyMessage = '检测到地址重复,请输入验证码以强制生成表单'
}
this.pendingCommand = command
this.verifyDialogVisible = true
},
// 处理验证码验证
handleVerify() {
if (!this.verifyInput || this.verifyInput.length !== 4) {
this.$modal.msgError('请输入四位数字验证码')
return
}
if (this.verifyInput !== this.verifyCode) {
this.$modal.msgError('验证码错误,请重新输入')
this.verifyInput = ''
return
}
// 验证通过使用forceGenerate参数重新执行
this.verifyLoading = true
executeInstructionWithForce({ 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.checkBombAlert(this.resultList)
// 执行成功后刷新历史记录
this.loadHistory()
this.$modal.msgSuccess('表单已强制生成')
} else {
this.$modal.msgError(res && res.msg ? res.msg : '执行失败')
}
}).catch(() => {
this.verifyLoading = false
this.$modal.msgError('执行失败,请稍后重试')
})
},
loadHistory() {
this.historyLoading = true
Promise.all([
getHistory('request', this.historyLimit),
getHistory('response', this.historyLimit)
]).then(([reqRes, respRes]) => {
this.historyLoading = false
if (reqRes && reqRes.code === 200) {
this.requestHistory = reqRes.data || []
}
if (respRes && respRes.code === 200) {
this.responseHistory = respRes.data || []
}
}).catch(() => {
this.historyLoading = false
this.$modal.msgError('加载历史记录失败')
})
},
extractTime(item) {
if (!item) return ''
const idx = item.indexOf(' | ')
return idx > 0 ? item.substring(0, idx) : ''
},
extractMessage(item) {
if (!item) return ''
const idx = item.indexOf(' | ')
return idx > 0 ? item.substring(idx + 3) : item
},
fillTF() {
this.form.command = 'TF'
},
fillSheng() {
this.form.command = '生'
this.run()
},
fillFan() {
this.form.command = '生\r\nF'
this.run()
},
fillWen() {
this.form.command = '生\r\nW'
this.run()
},
fillHong() {
this.form.command = '生\r\nH'
this.run()
},
fillPDD() {
this.form.command = '拼多多\r\n'
this.run()
},
fillPDDWen() {
this.form.command = '拼多多 W\r\n'
this.run()
},
async fillMan() {
// 先尝试查询今天的数据
this.form.command = '慢单'
this.loading = true
try {
const res = await executeInstruction({ command: '慢单' })
this.loading = false
if (res && (res.code === 200 || res.msg === '操作成功')) {
const data = res.data
let resultData = []
if (Array.isArray(data)) resultData = data
else if (typeof data === 'string') resultData = data ? [data] : []
else resultData = []
// 如果今天的数据为空,尝试查询昨天的数据
if (resultData.length === 0 || (resultData.length === 1 && resultData[0].includes('无数据'))) {
this.$message.info('今天暂无慢单数据,正在查询昨天的数据...')
// 获取昨天的日期
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0].replace(/-/g, '')
this.loading = true
const yesterdayRes = await executeInstruction({ command: `慢单${yesterdayStr}` })
this.loading = false
if (yesterdayRes && (yesterdayRes.code === 200 || yesterdayRes.msg === '操作成功')) {
const yesterdayData = yesterdayRes.data
if (Array.isArray(yesterdayData)) this.resultList = yesterdayData
else if (typeof yesterdayData === 'string') this.resultList = yesterdayData ? [yesterdayData] : []
else this.resultList = []
if (this.resultList.length > 0) {
this.$message.success(`已查询到昨天(${yesterdayStr})的慢单数据`)
} else {
this.$message.warning('昨天也没有慢单数据')
this.resultList = []
}
} else {
this.$message.error('查询昨天数据失败')
this.resultList = []
}
} else {
// 今天有数据,直接显示
this.resultList = resultData
this.$message.success('已查询到今天的慢单数据')
}
// 检查是否有以[炸弹]开头的消息
this.checkBombAlert(this.resultList)
// 执行成功后刷新历史记录
this.loadHistory()
} else {
this.$message.error(res && res.msg ? res.msg : '查询失败')
this.resultList = []
}
} catch (error) {
this.loading = false
this.$message.error('查询失败,请稍后重试')
this.resultList = []
}
},
clearAll() {
this.form.command = ''
this.resultList = []
},
checkBombAlert(resultList) {
if (!resultList || resultList.length === 0) return
// 检查是否有以[炸弹]开头的消息
const bombMessages = resultList
.filter(msg => {
return msg && typeof msg === 'string' && msg.trim().startsWith('[炸弹]')
})
.map(msg => {
// 移除所有的[炸弹]标记
return msg.trim().replace(/\[炸弹\]\s*/g, '').trim()
})
if (bombMessages.length > 0) {
// 显示全屏警告弹窗
this.$alert(bombMessages.join('\n\n'), '⚠️ 警告提示', {
confirmButtonText: '我已知晓',
type: 'warning',
center: true,
customClass: 'bomb-alert-dialog',
showClose: false,
closeOnClickModal: false,
closeOnPressEscape: false,
dangerouslyUseHTMLString: false
}).catch(() => {})
}
},
copyHistoryItem(item) {
const message = this.extractMessage(item)
if (message) {
this.doCopy(message)
}
}
}
}
</script>
<style scoped>
.box-card { margin: 20px; }
.box-card {
margin: 20px;
}
/* 移动端卡片优化 */
@media (max-width: 768px) {
.box-card {
margin: 10px;
border-radius: 8px;
}
.box-card ::v-deep .el-card__header {
padding: 12px 16px;
font-size: 16px;
}
.box-card ::v-deep .el-card__body {
padding: 16px;
}
}
/* 响应容器 */
.response-container {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 8px;
}
.response-section {
border: 1px solid #DCDFE6;
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.response-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #F5F7FA;
border-bottom: 1px solid #DCDFE6;
font-weight: 600;
color: #303133;
font-size: 14px;
}
/* 完整消息区域 */
.response-content-full {
padding: 0;
background-color: #FFFFFF;
display: flex;
flex-direction: column;
}
.response-content-full ::v-deep .el-textarea {
flex: 1;
display: flex;
flex-direction: column;
}
.response-content-full ::v-deep .el-textarea__inner {
border: none;
border-radius: 0;
resize: none;
min-height: 400px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
/* 消息列表区域 */
.response-content-list {
padding: 16px;
background-color: #FFFFFF;
max-height: 600px;
overflow-y: auto;
}
/* 消息列表样式 */
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-item {
padding: 12px;
border-radius: 4px;
background-color: #F9FAFC;
border-left: 3px solid #409EFF;
transition: all 0.3s;
}
.message-item:hover {
background-color: #ECF5FF;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.message-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.message-index {
font-size: 12px;
color: #909399;
font-weight: 500;
}
.message-content {
font-size: 13px;
color: #303133;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
font-family: 'Courier New', monospace;
}
/* 滚动条美化 */
.response-content-list::-webkit-scrollbar {
width: 8px;
}
.response-content-list::-webkit-scrollbar-track {
background: #F5F7FA;
}
.response-content-list::-webkit-scrollbar-thumb {
background: #DCDFE6;
border-radius: 4px;
}
.response-content-list::-webkit-scrollbar-thumb:hover {
background: #C0C4CC;
}
/* 移动端响应容器优化 */
@media (max-width: 768px) {
.response-container {
gap: 12px;
}
.response-content-full ::v-deep .el-textarea__inner {
min-height: 250px;
font-size: 12px;
}
.response-content-list {
max-height: 400px;
padding: 12px;
}
.message-item {
padding: 10px;
}
.message-content {
font-size: 12px;
}
.message-item-header {
margin-bottom: 6px;
}
.response-header {
padding: 10px 12px;
font-size: 13px;
}
}
.msg-block { margin-bottom: 12px; }
.msg-header { display: flex; align-items: center; justify-content: space-between; margin: 6px 0; }
/* 按钮组样式 */
.button-group {
margin-bottom: 16px;
display: flex;
flex-wrap: wrap;
gap: 10px;
width: 100%;
}
.button-group .el-button {
margin-right: 0;
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
flex: 1;
min-width: 80px;
}
.button-group .el-button:last-child {
margin-right: 0;
}
/* 移动端按钮优化 */
@media (max-width: 768px) {
.button-group {
gap: 8px;
margin-bottom: 12px;
display: grid !important;
width: 100% !important;
}
.button-group .el-button {
padding: 12px 8px;
font-size: 13px;
height: 44px; /* 增大触摸目标 */
line-height: 1.2;
width: 100% !important;
margin: 0 !important;
flex: none !important;
min-width: 0 !important;
max-width: 100% !important;
}
/* 移动端第一行按钮组(执行、清空、慢单、腾峰)- 每行2个显示最常用的4个 */
.button-group-primary {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 8px !important;
width: 100% !important;
}
.button-group-primary .el-button {
width: 100% !important;
max-width: 100% !important;
}
/* 移动端:将腾峰移到第一组,第二组隐藏 */
.button-group-secondary {
display: none !important;
}
.button-group-secondary .el-button {
width: 100% !important;
max-width: 100% !important;
padding: 10px 4px;
font-size: 12px;
}
/* 确保按钮组独立于form-item */
.button-group {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
/* 历史记录控制条 */
.history-controls {
display: flex;
align-items: center;
margin-top: 16px;
margin-bottom: 12px;
padding: 12px 16px;
background-color: #F5F7FA;
border-radius: 4px;
border: 1px solid #DCDFE6;
}
.history-label {
font-size: 14px;
color: #606266;
margin-right: 12px;
font-weight: 500;
}
/* 历史消息容器 */
.history-container {
display: flex;
gap: 20px;
margin-top: 8px;
}
/* 移动端历史记录优化 */
@media (max-width: 768px) {
.history-container {
flex-direction: column;
gap: 12px;
}
.history-column {
width: 100%;
}
.history-content {
height: 300px;
}
.history-controls {
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
}
.history-label {
margin-right: 0;
margin-bottom: 0;
}
}
.history-column {
flex: 1;
border: 1px solid #DCDFE6;
border-radius: 4px;
overflow: hidden;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #F5F7FA;
border-bottom: 1px solid #DCDFE6;
font-weight: 600;
color: #303133;
}
.history-content {
height: 500px;
overflow-y: auto;
background-color: #FFFFFF;
}
.empty-history {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.history-list {
padding: 10px;
}
.history-item {
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 4px;
background-color: #F9FAFC;
border-left: 3px solid #409EFF;
transition: all 0.3s;
}
.history-item:hover {
background-color: #ECF5FF;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.history-time {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.history-text {
font-size: 13px;
color: #303133;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
/* 滚动条美化 */
.history-content::-webkit-scrollbar {
width: 8px;
}
.history-content::-webkit-scrollbar-track {
background: #F5F7FA;
}
.history-content::-webkit-scrollbar-thumb {
background: #DCDFE6;
border-radius: 4px;
}
.history-content::-webkit-scrollbar-thumb:hover {
background: #C0C4CC;
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 炸弹警告弹窗样式 */
::v-deep .bomb-alert-dialog {
width: 80% !important;
max-width: 1920px !important;
}
::v-deep .bomb-alert-dialog .el-message-box__message {
font-size: 16px !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;
}
::v-deep .bomb-alert-dialog .el-message-box__btns {
text-align: center !important;
}
::v-deep .bomb-alert-dialog .el-message-box__btns .el-button {
padding: 12px 40px !important;
font-size: 16px !important;
font-weight: 600 !important;
}
</style>
<style>
/* 全局样式炸弹警告弹窗不使用scoped因为弹窗挂载在body下 */
.bomb-alert-dialog {
width: 80vw !important;
max-width: 1920px !important;
min-width: 500px !important;
}
.bomb-alert-dialog .el-message-box__header {
padding: 20px 20px 15px !important;
}
.bomb-alert-dialog .el-message-box__title {
font-size: 20px !important;
font-weight: 700 !important;
}
.bomb-alert-dialog .el-message-box__message {
font-size: 16px !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;
}
.bomb-alert-dialog .el-message-box__btns {
text-align: center !important;
padding: 15px 20px 20px !important;
}
.bomb-alert-dialog .el-message-box__btns .el-button {
padding: 12px 40px !important;
font-size: 16px !important;
font-weight: 600 !important;
}
</style>

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,749 @@
<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" 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">
<template v-if="config.currentProgress <= (form.startRow + 49)">
{{ form.startRow }}
</template>
<template v-else-if="config.currentProgress > (form.startRow + 100)">
{{ config.currentProgress - 100 }}
</template>
<template v-else>
{{ form.startRow }}
</template>
</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,976 @@
<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': '部分成功',
'FAILED': '失败'
}
return statusMap[status] || status
},
getStatusTagType(status) {
const typeMap = {
'RUNNING': 'warning',
'SUCCESS': 'success',
'PARTIAL': 'warning',
'FAILED': 'danger'
}
return typeMap[status] || 'info'
},
getRecordType(status) {
const typeMap = {
'SUCCESS': 'success',
'PARTIAL': 'warning',
'FAILED': 'danger',
'RUNNING': 'primary'
}
return typeMap[status] || 'info'
},
getRecordIcon(status) {
const iconMap = {
'SUCCESS': 'el-icon-success',
'PARTIAL': 'el-icon-warning',
'FAILED': 'el-icon-error',
'RUNNING': 'el-icon-loading'
}
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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,21 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<!-- 顶部搜索区域 -->
<div class="search-section">
<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="unionId">
<el-select v-model="queryParams.unionId" placeholder="请选择京粉账号" clearable style="width: 240px">
<el-option
@@ -25,13 +40,92 @@
<el-form-item label="订单时间">
<el-date-picker v-model="dateRange" style="width: 240px" value-format="yyyy-MM-dd" type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期"></el-date-picker>
</el-form-item>
<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>
<el-row :gutter="10" class="mb8">
<!-- 统计悬浮模块 -->
<el-card class="statistics-card" shadow="hover" v-if="orderrowsList.length > 0">
<div slot="header" class="clearfix">
<span><i class="el-icon-data-analysis"></i> 佣金统计</span>
<el-button style="float: right; padding: 3px 0" type="text" @click="toggleStatistics">
{{ showStatistics ? '收起' : '展开' }}
</el-button>
</div>
<div v-show="showStatistics" class="statistics-content">
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-item">
<div class="stat-label">总订单数</div>
<div class="stat-value">{{ statistics.totalOrders }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-label">总计佣金额</div>
<div class="stat-value">¥{{ statistics.totalCosPrice.toFixed(2) }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-label">预估佣金</div>
<div class="stat-value">¥{{ statistics.totalEstimateFee.toFixed(2) }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-label">实际佣金</div>
<div class="stat-value">¥{{ statistics.totalActualFee.toFixed(2) }}</div>
</div>
</el-col>
</el-row>
<el-divider></el-divider>
<el-row :gutter="20">
<el-col :span="12">
<div class="status-stats">
<h4>按状态统计</h4>
<div class="status-list">
<div v-for="(stat, key) in statistics.statusStats" :key="key" class="status-item">
<el-tag :type="getStatusTypeByKey(key)" size="small">{{ stat.label }}</el-tag>
<span class="status-count">{{ stat.count }}</span>
<span class="status-amount">¥{{ stat.amount.toFixed(2) }}</span>
</div>
</div>
</div>
</el-col>
<el-col :span="12">
<div class="account-stats">
<h4>按账号统计</h4>
<div class="account-list">
<div v-for="(stat, unionId) in statistics.accountStats" :key="unionId" class="account-item">
<span class="account-name">{{ getAdminName(unionId) }}</span>
<span class="account-count">{{ stat.count }}</span>
<span class="account-amount">¥{{ stat.amount.toFixed(2) }}</span>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
</el-card>
<!-- 操作按钮区域移动端单独显示 -->
<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="['system:orderrows:add']">新增</el-button>
</el-col>
@@ -46,7 +140,10 @@
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<!-- 表格区域 - 可滚动 -->
<div class="table-section">
<el-table v-loading="loading" :data="orderrowsList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="账号" align="center" prop="unionId" width="50">
@@ -56,7 +153,7 @@
</el-table-column>
<el-table-column label="订单号" align="center" prop="orderId" width="120" />
<el-table-column label="商品名称" align="center" prop="skuName" :show-overflow-tooltip="true" min-width="200" />
<el-table-column label="计佣金额" align="center" prop="estimateCosPrice" width="100">
<el-table-column label="计佣金额" align="center" prop="estimateCosPrice" width="100" sortable="custom" @sort-change="handleSortChange">
<template slot-scope="scope">
<span>¥{{ scope.row.estimateCosPrice }}</span>
</template>
@@ -102,8 +199,12 @@
</template>
</el-table-column>
</el-table>
</div>
<!-- 固定分页区域 -->
<div class="pagination-section">
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
</div>
<!-- 查看订单详情对话框 -->
<el-dialog :title="'订单详情 - ' + currentOrder.orderId" :visible.sync="viewDialogVisible" width="900px" append-to-body>
@@ -217,11 +318,18 @@
</template>
<script>
import { listOrderrows, getOrderrows, delOrderrows, addOrderrows, updateOrderrows, getValidCodeSelectData } from "@/api/system/orderrows";
import { listOrderrows, getOrderrows, delOrderrows, addOrderrows, updateOrderrows, getValidCodeSelectData, getOrderStatistics } from "@/api/system/orderrows";
import { getAdminSelectData } from "@/api/system/superadmin";
import { mapGetters } from 'vuex'
import MobileSearchForm from '@/components/MobileSearchForm'
import MobileButtonGroup from '@/components/MobileButtonGroup'
export default {
name: "Orderrows",
components: {
MobileSearchForm,
MobileButtonGroup
},
data() {
return {
// 遮罩层
@@ -252,7 +360,9 @@ export default {
orderId: null,
skuName: null,
validCode: null,
statusGroup: null // 新增
statusGroup: null, // 新增
orderBy: null, // 排序字段
orderSort: null // 排序方向asc/desc
},
// 管理员列表
adminList: [],
@@ -263,14 +373,61 @@ export default {
// 查看详情对话框
viewDialogVisible: false,
// 当前查看的订单
currentOrder: {}
currentOrder: {},
// 统计相关
showStatistics: true,
statistics: {
totalOrders: 0,
totalCosPrice: 0,
totalEstimateFee: 0,
totalActualFee: 0,
statusStats: {},
accountStats: {}
}
};
},
computed: {
...mapGetters(['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: 'export', label: '导出', type: 'warning', icon: 'el-icon-download', handler: () => this.handleExport(), disabled: false }
]
}
},
created() {
this.getList();
this.getAdminList();
this.getStatusList();
},
watch: {
// 监听日期范围变化,自动调整分页条数
dateRange: {
handler(newVal) {
if (newVal && newVal.length === 2) {
// 选择了日期范围设置分页条数为1000
this.queryParams.pageSize = 1000;
this.queryParams.pageNum = 1;
} else {
// 清空日期范围,恢复默认分页条数
this.queryParams.pageSize = 10;
this.queryParams.pageNum = 1;
}
},
deep: true
}
},
methods: {
/** 查询京粉订单列表 */
getList() {
@@ -279,12 +436,106 @@ export default {
this.orderrowsList = response.rows;
this.total = response.total;
this.loading = false;
// 调用后端统计接口计算统计数据
this.loadStatistics();
}).catch(error => {
console.error('获取订单列表失败:', error);
this.loading = false;
this.$message.error('获取订单列表失败');
});
},
/** 加载统计数据(从后端获取) */
loadStatistics() {
// 构建统计查询参数,使用与列表查询相同的条件
const statParams = {
...this.queryParams,
beginTime: this.dateRange && this.dateRange.length > 0 ? this.dateRange[0] : null,
endTime: this.dateRange && this.dateRange.length > 1 ? this.dateRange[1] : null
};
getOrderStatistics(statParams).then(response => {
const data = response.data || {};
const groupStats = data.groupStats || {};
// 转换后端返回的统计格式为前端需要的格式
this.statistics = {
totalOrders: data.totalOrders || 0,
totalCosPrice: 0, // 后端没有返回此字段,如果需要可以前端计算或后端添加
totalEstimateFee: data.totalCommission || 0,
totalActualFee: data.totalActualFee || 0,
statusStats: {
cancel: this.convertGroupStat(groupStats.cancel),
invalid: this.convertGroupStat(groupStats.invalid),
pending: this.convertGroupStat(groupStats.pending),
paid: this.convertGroupStat(groupStats.paid),
finished: this.convertGroupStat(groupStats.finished),
deposit: this.convertGroupStat(groupStats.deposit),
illegal: this.convertGroupStat(groupStats.illegal)
},
accountStats: {} // 后端没有按账号统计,保留为空或后续添加
};
// 如果有订单列表,计算总计佣金额和按账号统计(这些前端计算更快)
if (this.orderrowsList.length > 0) {
let totalCosPrice = 0;
const accountStats = {};
this.orderrowsList.forEach(order => {
// 总计佣金额
if (order.estimateCosPrice) {
totalCosPrice += parseFloat(order.estimateCosPrice) || 0;
}
// 按账号统计
const unionId = order.unionId;
if (!accountStats[unionId]) {
accountStats[unionId] = {
count: 0,
amount: 0
};
}
accountStats[unionId].count++;
// 计算账号佣金金额(使用与后端相同的逻辑)
const validCode = String(order.validCode);
const isCancel = validCode === '3';
const isIllegal = ['25', '26', '27', '28'].includes(validCode);
let commissionAmount = parseFloat(order.actualFee) || 0;
if (isIllegal && order.estimateCosPrice && order.commissionRate) {
commissionAmount = parseFloat(order.estimateCosPrice) * parseFloat(order.commissionRate) / 100;
} else if (isCancel && (!order.actualFee || parseFloat(order.actualFee) === 0)
&& order.estimateCosPrice && order.commissionRate) {
commissionAmount = parseFloat(order.estimateCosPrice) * parseFloat(order.commissionRate) / 100;
}
accountStats[unionId].amount += commissionAmount;
});
this.statistics.totalCosPrice = totalCosPrice;
this.statistics.accountStats = accountStats;
}
}).catch(error => {
console.error('获取统计数据失败:', error);
// 如果后端统计失败,回退到前端计算
this.calculateStatistics();
});
},
/** 转换后端分组统计格式 */
convertGroupStat(groupStat) {
if (!groupStat) {
return {
label: '',
count: 0,
amount: 0
};
}
return {
label: groupStat.label || '',
count: groupStat.count || 0,
amount: groupStat.actualFee || 0 // 使用actualFee作为金额
};
},
/** 获取管理员列表 */
getAdminList() {
getAdminSelectData().then(response => {
@@ -446,6 +697,7 @@ export default {
handleQuery() {
this.queryParams.pageNum = 1;
console.log(this.queryParams.validCode);
// 合并项转为原始code数组
if (this.queryParams.statusGroup) {
this.queryParams.validCodes = this.statusValueMap[this.queryParams.statusGroup].map(code => Number(code));
@@ -467,7 +719,31 @@ export default {
this.dateRange = [];
this.resetForm("queryForm");
this.queryParams.validCodes = [];
this.handleQuery();
// 重置时恢复默认分页条数
this.queryParams.pageSize = 10;
this.queryParams.pageNum = 1;
// 重置排序
this.queryParams.orderBy = null;
this.queryParams.orderSort = null;
this.getList();
},
/** 排序变化处理 */
handleSortChange(column) {
if (column.prop === 'estimateCosPrice') {
if (column.order === 'ascending') {
this.queryParams.orderBy = 'estimateCosPrice';
this.queryParams.orderSort = 'asc';
} else if (column.order === 'descending') {
this.queryParams.orderBy = 'estimateCosPrice';
this.queryParams.orderSort = 'desc';
} else {
// 取消排序
this.queryParams.orderBy = null;
this.queryParams.orderSort = null;
}
this.queryParams.pageNum = 1;
this.getList();
}
},
// 多选框选中数据
handleSelectionChange(selection) {
@@ -531,7 +807,381 @@ export default {
this.download('/jarvis/orderrows/export', {
...this.queryParams
}, `京粉订单数据_${new Date().getTime()}.xlsx`)
},
/** 切换统计显示 */
toggleStatistics() {
this.showStatistics = !this.showStatistics;
},
/** 计算统计数据 */
calculateStatistics() {
const stats = {
totalOrders: this.orderrowsList.length,
totalCosPrice: 0,
totalEstimateFee: 0,
totalActualFee: 0,
statusStats: {},
accountStats: {}
};
// 状态分组映射
const statusGroups = {
'cancel': { label: '取消', codes: ['3'] },
'invalid': { label: '无效', codes: ['2','4','5','6','7','8','9','10','11','14','19','20','21','22','23','29','30','31','32','33','34'] },
'pending': { label: '待付款', codes: ['15'] },
'paid': { label: '已付款', codes: ['16'] },
'finished': { label: '已完成', codes: ['17'] },
'deposit': { label: '已付定金', codes: ['24'] },
'illegal': { label: '违规', codes: ['25','26','27','28'] }
};
// 初始化状态统计
Object.keys(statusGroups).forEach(key => {
stats.statusStats[key] = {
label: statusGroups[key].label,
count: 0,
amount: 0
};
});
// 遍历订单数据计算统计
this.orderrowsList.forEach(order => {
// 总计佣金额
if (order.estimateCosPrice) {
stats.totalCosPrice += parseFloat(order.estimateCosPrice) || 0;
}
// 预估佣金
if (order.estimateFee) {
stats.totalEstimateFee += parseFloat(order.estimateFee) || 0;
}
// 计算实际佣金或预估佣金
// 对于违规订单25,26,27,28始终使用 estimateCosPrice * commissionRate / 100 计算
// 对于取消订单3如果actualFee为空或0则通过公式计算
const validCode = String(order.validCode);
const isCancel = validCode === '3'; // 取消订单
const isIllegal = ['25', '26', '27', '28'].includes(validCode); // 违规订单
let commissionAmount = parseFloat(order.actualFee) || 0;
const estimateCosPrice = parseFloat(order.estimateCosPrice) || 0;
const commissionRate = parseFloat(order.commissionRate) || 0;
// 违规订单始终使用公式计算佣金
if (isIllegal && estimateCosPrice > 0 && commissionRate > 0) {
commissionAmount = estimateCosPrice * commissionRate / 100;
}
// 取消订单如果actualFee为空或0则通过公式计算
else if (isCancel && (!order.actualFee || parseFloat(order.actualFee) === 0) && estimateCosPrice > 0 && commissionRate > 0) {
commissionAmount = estimateCosPrice * commissionRate / 100;
}
// 实际佣金累计(包含计算出的违规和取消订单佣金)
stats.totalActualFee += commissionAmount;
// 按状态统计
let statusKey = 'invalid'; // 默认无效
for (const [key, group] of Object.entries(statusGroups)) {
if (group.codes.includes(validCode)) {
statusKey = key;
break;
}
}
stats.statusStats[statusKey].count++;
stats.statusStats[statusKey].amount += commissionAmount;
// 按账号统计
const unionId = order.unionId;
if (!stats.accountStats[unionId]) {
stats.accountStats[unionId] = {
count: 0,
amount: 0
};
}
stats.accountStats[unionId].count++;
stats.accountStats[unionId].amount += commissionAmount;
});
this.statistics = stats;
},
/** 根据状态键获取标签类型 */
getStatusTypeByKey(key) {
const typeMap = {
'cancel': 'danger',
'invalid': 'info',
'pending': 'warning',
'paid': 'primary',
'finished': 'success',
'deposit': 'warning',
'illegal': 'danger'
};
return typeMap[key] || 'info';
}
}
};
</script>
<style scoped>
/* 主容器布局 */
.app-container {
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);
}
/* 表格区域 - 可滚动 */
.table-section {
flex: 1;
overflow: auto;
padding: 0 20px;
background: #fff;
}
/* 固定分页区域 */
.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;
}
/* 统计卡片样式 */
.statistics-card {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.statistics-card .el-card__header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px 8px 0 0;
padding: 15px 20px;
}
.statistics-card .el-card__header span {
font-weight: 600;
font-size: 16px;
}
.statistics-card .el-card__header .el-button {
color: white;
font-weight: 500;
}
.statistics-card .el-card__header .el-button:hover {
color: #f0f0f0;
}
.statistics-content {
padding: 20px 0;
}
.stat-item {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
margin-bottom: 10px;
transition: all 0.3s ease;
}
.stat-item:hover {
background: #e9ecef;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
font-weight: 500;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.status-stats, .account-stats {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
height: 100%;
}
.status-stats h4, .account-stats h4 {
margin: 0 0 15px 0;
color: #2c3e50;
font-size: 16px;
font-weight: 600;
border-bottom: 2px solid #e9ecef;
padding-bottom: 8px;
}
.status-list, .account-list {
max-height: 200px;
overflow-y: auto;
}
.status-item, .account-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #e9ecef;
}
.status-item:last-child, .account-item:last-child {
border-bottom: none;
}
.status-count, .account-count {
font-size: 12px;
color: #666;
background: #e9ecef;
padding: 2px 8px;
border-radius: 12px;
min-width: 40px;
text-align: center;
}
.status-amount, .account-amount {
font-weight: 600;
color: #27ae60;
font-size: 14px;
}
.account-name {
font-weight: 500;
color: #2c3e50;
flex: 1;
margin-right: 10px;
}
/* 滚动条样式 */
.status-list::-webkit-scrollbar, .account-list::-webkit-scrollbar {
width: 4px;
}
.status-list::-webkit-scrollbar-track, .account-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 2px;
}
.status-list::-webkit-scrollbar-thumb, .account-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 2px;
}
.status-list::-webkit-scrollbar-thumb:hover, .account-list::-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;
}
/* 响应式设计 */
@media (max-width: 768px) {
.app-container {
height: calc(100vh - 50px); /* 移动端调整高度 */
}
.search-section {
padding: 15px;
}
.table-section {
padding: 0 15px;
}
.pagination-section {
padding: 10px 15px;
}
.statistics-content .el-col {
margin-bottom: 15px;
}
.stat-item {
padding: 10px;
}
.stat-value {
font-size: 20px;
}
}
/* 确保表格在容器内正确显示 */
.table-section .el-table {
width: 100%;
}
/* 分页组件样式优化 */
.pagination-section .pagination-container {
display: flex;
justify-content: center;
align-items: center;
background: #fff;
border-radius: 4px;
padding: 10px 0;
}
/* 操作按钮区域 */
.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

@@ -1,6 +1,21 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch">
<div>
<list-layout>
<!-- 搜索区域 -->
<template #search>
<mobile-search-form
:model="queryParams"
@search="handleQuery"
@reset="resetQuery"
>
<template #form="{ expanded }">
<el-form
:model="queryParams"
ref="queryForm"
size="small"
:inline="true"
label-width="68px"
>
<el-form-item label="角色名称" prop="roleName">
<el-input
v-model="queryParams.roleName"
@@ -45,13 +60,25 @@
end-placeholder="结束日期"
></el-date-picker>
</el-form-item>
<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>
<el-row :gutter="10" class="mb8">
<!-- 操作按钮区域移动端单独显示 -->
<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"
@@ -96,7 +123,10 @@
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</template>
<!-- 表格区域 -->
<template #table>
<el-table v-loading="loading" :data="roleList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="角色编号" prop="roleId" width="120" />
@@ -146,7 +176,10 @@
</template>
</el-table-column>
</el-table>
</template>
<!-- 分页区域 -->
<template #pagination>
<pagination
v-show="total > 0"
:total="total"
@@ -154,6 +187,8 @@
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</template>
</list-layout>
<!-- 添加或修改角色配置对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
@@ -254,9 +289,18 @@
<script>
import { listRole, getRole, delRole, addRole, updateRole, dataScope, changeRoleStatus, deptTreeSelect } from "@/api/system/role"
import { treeselect as menuTreeselect, roleMenuTreeselect } from "@/api/system/menu"
import { mapGetters } from 'vuex'
import ListLayout from "@/components/ListLayout"
import MobileSearchForm from '@/components/MobileSearchForm'
import MobileButtonGroup from '@/components/MobileButtonGroup'
export default {
name: "Role",
components: {
ListLayout,
MobileSearchForm,
MobileButtonGroup
},
dicts: ['sys_normal_disable'],
data() {
return {
@@ -341,6 +385,26 @@ export default {
}
}
},
computed: {
...mapGetters(['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: 'export', label: '导出', type: 'warning', icon: 'el-icon-download', handler: () => this.handleExport(), disabled: false }
]
}
},
created() {
this.getList()
},
@@ -603,3 +667,32 @@ export default {
}
}
</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-only {
display: block;
}
}
</style>

View File

@@ -0,0 +1,917 @@
<template>
<div class="social-media-container">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span class="card-title">
<i class="el-icon-star-on"></i>
小红书/抖音内容生成工具
</span>
<el-button
style="float: right; padding: 3px 0"
type="text"
@click="showHelp = !showHelp">
{{ showHelp ? '隐藏帮助' : '显示帮助' }}
</el-button>
</div>
<!-- 帮助说明 -->
<el-collapse-transition>
<div v-show="showHelp" class="help-section">
<el-alert
title="使用说明"
type="info"
:closable="false"
show-icon>
<div slot="default">
<p><strong>功能说明</strong></p>
<ul>
<li>一键生成小红书/抖音推广内容关键词文案营销图片</li>
<li>AI智能提取商品关键词</li>
<li>AI生成多种风格的推广文案</li>
<li>自动合成营销对比图片1080x1080</li>
<li>支持快捷复制图片和文案</li>
</ul>
<p><strong>使用步骤</strong></p>
<ol>
<li>输入商品信息图片URL名称价格</li>
<li>选择文案风格小红书/抖音/通用</li>
<li>点击"一键生成"或分步生成</li>
<li>在结果区域复制图片和文案</li>
</ol>
</div>
</el-alert>
</div>
</el-collapse-transition>
<!-- 商品信息输入区域 -->
<div class="input-section">
<el-form :model="form" label-width="120px" class="main-form">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商品主图URL" required>
<el-input
v-model="form.productImageUrl"
placeholder="请输入商品主图URL"
clearable>
<el-button slot="append" @click="handlePreviewImage(form.productImageUrl)" :disabled="!form.productImageUrl">
预览
</el-button>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品名称" required>
<el-input
v-model="form.productName"
placeholder="请输入完整商品名称"
clearable>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="官网价">
<el-input-number
v-model="form.originalPrice"
:min="0"
:precision="2"
:step="1"
placeholder="官网原价"
style="width: 100%">
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="到手价" required>
<el-input-number
v-model="form.finalPrice"
:min="0"
:precision="2"
:step="1"
placeholder="到手价"
style="width: 100%">
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="文案风格">
<el-select v-model="form.style" placeholder="选择风格" style="width: 100%">
<el-option label="小红书风格" value="xhs">
<span style="float: left">小红书风格</span>
<span style="float: right; color: #8492a6; font-size: 13px">真实种草</span>
</el-option>
<el-option label="抖音风格" value="douyin">
<span style="float: left">抖音风格</span>
<span style="float: right; color: #8492a6; font-size: 13px">直接有冲击力</span>
</el-option>
<el-option label="通用风格" value="both">
<span style="float: left">通用风格</span>
<span style="float: right; color: #8492a6; font-size: 13px">适合多平台</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button
type="primary"
icon="el-icon-magic-stick"
size="medium"
@click="handleGenerateComplete"
:loading="generating">
一键生成全部
</el-button>
<el-button
type="success"
icon="el-icon-search"
@click="handleExtractKeywords"
:loading="extractingKeywords">
提取关键词
</el-button>
<el-button
type="warning"
icon="el-icon-edit"
@click="handleGenerateContent"
:loading="generatingContent">
生成文案
</el-button>
<el-button
icon="el-icon-refresh-left"
@click="handleReset">
重置
</el-button>
<el-button
type="info"
icon="el-icon-connection"
@click="showParseDialog = true">
从解析接口导入
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 关键词展示区域 -->
<div v-if="result.keywords && result.keywords.length > 0" class="keywords-section">
<el-divider content-position="left">
<span style="font-size: 16px; font-weight: bold;">
<i class="el-icon-collection-tag"></i>
提取的关键词
</span>
</el-divider>
<div class="keywords-display">
<el-tag
v-for="(keyword, index) in result.keywords"
:key="index"
type="primary"
effect="dark"
size="medium"
style="margin: 5px; cursor: pointer;"
@click="handleCopyText(keyword)">
{{ keyword }}
<i class="el-icon-document-copy" style="margin-left: 5px;"></i>
</el-tag>
<el-button
type="text"
icon="el-icon-document-copy"
@click="handleCopyText(result.keywordsText)"
style="margin-left: 10px;">
复制全部关键词
</el-button>
</div>
</div>
<!-- 文案展示区域 -->
<div v-if="result.content" class="content-section">
<el-divider content-position="left">
<span style="font-size: 16px; font-weight: bold;">
<i class="el-icon-edit-outline"></i>
生成的文案
</span>
<el-button
type="text"
icon="el-icon-document-copy"
@click="handleCopyText(result.content)"
style="float: right; margin-top: -5px;">
复制文案
</el-button>
</el-divider>
<div class="content-display">
<el-input
v-model="result.content"
type="textarea"
:rows="8"
readonly
class="content-textarea">
</el-input>
<div class="content-actions">
<el-button
size="small"
icon="el-icon-document-copy"
@click="handleCopyText(result.content)">
复制文案
</el-button>
<el-button
size="small"
icon="el-icon-refresh"
@click="handleRegenerateContent">
重新生成
</el-button>
</div>
</div>
</div>
<!-- 图片展示区域 -->
<div v-if="result.imageBase64" class="image-section">
<el-divider content-position="left">
<span style="font-size: 16px; font-weight: bold;">
<i class="el-icon-picture"></i>
生成的营销图片
</span>
</el-divider>
<div class="image-display">
<div class="image-wrapper">
<img
:src="result.imageBase64"
alt="营销图片"
class="result-image"
@click="handlePreviewLargeImage(result.imageBase64)">
<div class="image-overlay">
<el-button-group>
<el-button
type="primary"
size="small"
icon="el-icon-download"
@click="handleDownloadImage(result.imageBase64, `营销图片_${form.productName || '商品'}_${Date.now()}.jpg`)">
下载
</el-button>
<el-button
type="success"
size="small"
icon="el-icon-document-copy"
@click="handleCopyImage(result.imageBase64)">
复制图片
</el-button>
<el-button
type="info"
size="small"
icon="el-icon-zoom-in"
@click="handlePreviewLargeImage(result.imageBase64)">
查看大图
</el-button>
</el-button-group>
</div>
</div>
</div>
</div>
<!-- 快捷操作区域 -->
<div v-if="result.content || result.imageBase64" class="quick-actions-section">
<el-divider content-position="left">
<span style="font-size: 16px; font-weight: bold;">
<i class="el-icon-s-operation"></i>
快捷操作
</span>
</el-divider>
<div class="quick-actions">
<el-button
type="primary"
icon="el-icon-document-copy"
@click="handleCopyAll">
复制全部文案+图片链接
</el-button>
<el-button
type="success"
icon="el-icon-download"
@click="handleDownloadAll">
下载图片
</el-button>
<el-button
icon="el-icon-delete"
@click="handleClearResult">
清空结果
</el-button>
</div>
</div>
</el-card>
<!-- 大图预览对话框 -->
<el-dialog
title="图片预览"
:visible.sync="previewDialogVisible"
width="80%"
:before-close="handleClosePreview">
<div class="large-preview">
<img
:src="previewLargeImageUrl"
alt="预览"
style="max-width: 100%; height: auto;">
</div>
</el-dialog>
<!-- 从解析接口导入对话框 -->
<el-dialog
title="从解析接口导入"
:visible.sync="showParseDialog"
width="70%"
:close-on-click-modal="false">
<div>
<el-alert
title="输入格式:每行一个商品,格式为:京东短链接 + Tab/空格 + 到手价"
type="info"
:closable="false"
style="margin-bottom: 20px;">
<div slot="default">
<p>示例</p>
<pre style="background: #f5f7fa; padding: 10px; border-radius: 4px; margin-top: 10px;">https://u.jd.com/W17zHk2 199
https://u.jd.com/W17zcSF 349</pre>
</div>
</el-alert>
<el-input
v-model="parseText"
type="textarea"
:rows="10"
placeholder="请输入商品链接和价格,每行一个..."
style="margin-bottom: 20px;">
</el-input>
<div slot="footer" class="dialog-footer">
<el-button @click="showParseDialog = false">取消</el-button>
<el-button
type="primary"
@click="handleCallParse"
:loading="parsing">
解析
</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { extractKeywords, generateContent, generateComplete } from '@/api/jarvis/socialMedia'
import { parseLineReport } from '@/api/jarvis/batchPublish'
export default {
name: 'SocialMedia',
data() {
return {
showHelp: false,
// 表单数据
form: {
productImageUrl: '',
productName: '',
originalPrice: null,
finalPrice: null,
style: 'xhs'
},
// 结果数据
result: {
keywords: [],
keywordsText: '',
content: '',
imageBase64: ''
},
// 加载状态
generating: false,
extractingKeywords: false,
generatingContent: false,
parsing: false,
// 预览对话框
previewDialogVisible: false,
previewLargeImageUrl: '',
// 解析对话框
showParseDialog: false,
parseText: ''
}
},
mounted() {
// 尝试从localStorage加载上次的数据
this.loadFromStorage()
},
methods: {
/** 一键生成全部 */
async handleGenerateComplete() {
// 验证表单
if (!this.form.productName) {
this.$message.warning('请输入商品名称')
return
}
if (this.form.finalPrice === null || this.form.finalPrice <= 0) {
this.$message.warning('请输入有效的到手价')
return
}
this.generating = true
try {
const res = await generateComplete({
productImageUrl: this.form.productImageUrl || undefined,
productName: this.form.productName,
originalPrice: this.form.originalPrice || undefined,
finalPrice: this.form.finalPrice,
style: this.form.style
})
if (res.code === 200 && res.data) {
const data = res.data
if (data.success) {
this.result = {
keywords: data.keywords || [],
keywordsText: data.keywordsText || '',
content: data.content || '',
imageBase64: data.imageBase64 || ''
}
// 保存到localStorage
this.saveToStorage()
this.$message.success('生成成功!')
// 滚动到结果区域
this.$nextTick(() => {
if (this.result.keywords && this.result.keywords.length > 0) {
const keywordsSection = document.querySelector('.keywords-section')
if (keywordsSection) {
keywordsSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
})
} else {
this.$message.error(data.error || '生成失败')
}
} else {
this.$message.error(res.msg || '生成失败')
}
} catch (error) {
console.error('生成失败', error)
this.$message.error('生成失败:' + (error.message || '未知错误'))
} finally {
this.generating = false
}
},
/** 提取关键词 */
async handleExtractKeywords() {
if (!this.form.productName) {
this.$message.warning('请输入商品名称')
return
}
this.extractingKeywords = true
try {
const res = await extractKeywords({
productName: this.form.productName
})
if (res.code === 200 && res.data) {
const data = res.data
if (data.success) {
this.result.keywords = data.keywords || []
this.result.keywordsText = data.keywordsText || ''
this.$message.success('关键词提取成功!')
} else {
this.$message.warning(data.error || '提取失败,已使用降级方案')
}
} else {
this.$message.error(res.msg || '提取失败')
}
} catch (error) {
console.error('提取关键词失败', error)
this.$message.error('提取失败:' + (error.message || '未知错误'))
} finally {
this.extractingKeywords = false
}
},
/** 生成文案 */
async handleGenerateContent() {
if (!this.form.productName) {
this.$message.warning('请输入商品名称')
return
}
this.generatingContent = true
try {
const res = await generateContent({
productName: this.form.productName,
originalPrice: this.form.originalPrice || undefined,
finalPrice: this.form.finalPrice || undefined,
keywords: this.result.keywordsText || undefined,
style: this.form.style
})
if (res.code === 200 && res.data) {
const data = res.data
if (data.success) {
this.result.content = data.content || ''
this.$message.success('文案生成成功!')
} else {
this.$message.error(data.error || '生成失败')
}
} else {
this.$message.error(res.msg || '生成失败')
}
} catch (error) {
console.error('生成文案失败', error)
this.$message.error('生成失败:' + (error.message || '未知错误'))
} finally {
this.generatingContent = false
}
},
/** 重新生成文案 */
async handleRegenerateContent() {
await this.handleGenerateContent()
},
/** 复制文本 */
handleCopyText(text) {
if (!text) {
this.$message.warning('没有可复制的内容')
return
}
// 创建临时文本域
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.$message.success('复制成功!')
} catch (error) {
// 降级方案
textarea.select()
try {
document.execCommand('copy')
this.$message.success('复制成功!')
} catch (e) {
this.$message.error('复制失败,请手动复制')
}
}
document.body.removeChild(textarea)
},
/** 复制图片 */
async handleCopyImage(base64) {
try {
const base64Data = base64.split(',')[1] || base64
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'image/jpeg' })
if (navigator.clipboard && navigator.clipboard.write) {
await navigator.clipboard.write([
new ClipboardItem({ 'image/jpeg': blob })
])
this.$message.success('图片已复制到剪贴板')
} else {
this.$message.info('浏览器不支持直接复制图片,请使用下载功能')
}
} catch (error) {
console.error('复制失败', error)
this.$message.error('复制失败')
}
},
/** 下载图片 */
handleDownloadImage(base64, filename) {
try {
const base64Data = base64.split(',')[1] || base64
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'image/jpeg' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
this.$message.success('下载成功')
} catch (error) {
console.error('下载失败', error)
this.$message.error('下载失败')
}
},
/** 复制全部 */
handleCopyAll() {
let allText = ''
if (this.result.keywordsText) {
allText += '关键词:' + this.result.keywordsText + '\n\n'
}
if (this.result.content) {
allText += this.result.content + '\n\n'
}
if (this.result.imageBase64) {
allText += '[营销图片已生成,请查看图片]'
}
if (allText) {
this.handleCopyText(allText)
} else {
this.$message.warning('没有可复制的内容')
}
},
/** 下载全部 */
handleDownloadAll() {
if (this.result.imageBase64) {
this.handleDownloadImage(
this.result.imageBase64,
`营销图片_${this.form.productName || '商品'}_${Date.now()}.jpg`
)
} else {
this.$message.warning('没有可下载的图片')
}
},
/** 清空结果 */
handleClearResult() {
this.$confirm('确定要清空所有结果吗?', '提示', {
type: 'warning'
}).then(() => {
this.result = {
keywords: [],
keywordsText: '',
content: '',
imageBase64: ''
}
this.$message.success('已清空')
}).catch(() => {})
},
/** 预览图片 */
handlePreviewImage(url) {
if (!url) return
this.previewLargeImageUrl = url
this.previewDialogVisible = true
},
/** 预览大图 */
handlePreviewLargeImage(base64) {
this.previewLargeImageUrl = base64
this.previewDialogVisible = true
},
/** 关闭预览 */
handleClosePreview() {
this.previewDialogVisible = false
this.previewLargeImageUrl = ''
},
/** 重置表单 */
handleReset() {
this.form = {
productImageUrl: '',
productName: '',
originalPrice: null,
finalPrice: null,
style: 'xhs'
}
this.result = {
keywords: [],
keywordsText: '',
content: '',
imageBase64: ''
}
},
/** 调用解析接口 */
async handleCallParse() {
if (!this.parseText || !this.parseText.trim()) {
this.$message.warning('请输入要解析的内容')
return
}
this.parsing = true
try {
const res = await parseLineReport({
message: this.parseText.trim()
})
if (res.code === 200 && res.data && Array.isArray(res.data) && res.data.length > 0) {
// 导入第一个商品
const item = res.data[0]
this.form = {
productImageUrl: item.productImage || '',
productName: item.productName || '',
originalPrice: item.price || null,
finalPrice: null // 需要手动输入
}
// 尝试从输入文本中提取价格
const lines = this.parseText.trim().split('\n')
lines.forEach(line => {
const parts = line.trim().split(/\s+/)
if (parts.length >= 2 && parts[0].includes(item._raw?.originalUrl || '')) {
const price = parseFloat(parts[1])
if (!isNaN(price)) {
this.form.finalPrice = price
}
}
})
this.$message.success('导入成功!请检查信息后生成内容')
this.showParseDialog = false
} else {
this.$message.error(res.msg || '解析失败')
}
} catch (error) {
console.error('调用解析接口失败', error)
this.$message.error('解析失败:' + (error.message || '未知错误'))
} finally {
this.parsing = false
}
},
/** 保存到localStorage */
saveToStorage() {
try {
localStorage.setItem('socialMediaForm', JSON.stringify(this.form))
localStorage.setItem('socialMediaResult', JSON.stringify(this.result))
} catch (error) {
console.error('保存失败', error)
}
},
/** 从localStorage加载 */
loadFromStorage() {
try {
const formStr = localStorage.getItem('socialMediaForm')
const resultStr = localStorage.getItem('socialMediaResult')
if (formStr) {
this.form = { ...this.form, ...JSON.parse(formStr) }
}
if (resultStr) {
this.result = { ...this.result, ...JSON.parse(resultStr) }
}
} catch (error) {
console.error('加载失败', error)
}
}
}
}
</script>
<style scoped>
.social-media-container {
padding: 20px;
}
.card-title {
font-size: 18px;
font-weight: bold;
}
.help-section {
margin-bottom: 20px;
}
.help-section ul,
.help-section ol {
margin: 10px 0;
padding-left: 20px;
}
.help-section li {
margin: 5px 0;
}
.input-section {
margin-top: 20px;
}
.main-form {
max-width: 100%;
}
.keywords-section,
.content-section,
.image-section,
.quick-actions-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #EBEEF5;
}
.keywords-display {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
}
.content-display {
position: relative;
}
.content-textarea {
font-size: 14px;
line-height: 1.6;
}
.content-actions {
margin-top: 10px;
text-align: right;
}
.image-display {
display: flex;
justify-content: center;
padding: 20px;
background: #f5f7fa;
border-radius: 4px;
}
.image-wrapper {
position: relative;
max-width: 500px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.result-image {
width: 100%;
height: auto;
display: block;
cursor: pointer;
transition: transform 0.3s;
}
.result-image:hover {
transform: scale(1.02);
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.image-wrapper:hover .image-overlay {
opacity: 1;
}
.quick-actions {
text-align: center;
padding: 20px;
}
.quick-actions .el-button {
margin: 0 10px;
}
.large-preview {
text-align: center;
max-height: 70vh;
overflow: auto;
}
.dialog-footer {
text-align: right;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div class="prompt-config-container">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span class="card-title">
<i class="el-icon-setting"></i>
DS提示词模板配置
</span>
<el-button
style="float: right; padding: 3px 0"
type="text"
@click="showHelp = !showHelp">
{{ showHelp ? '隐藏帮助' : '显示帮助' }}
</el-button>
</div>
<!-- 帮助说明 -->
<el-collapse-transition>
<div v-show="showHelp" class="help-section">
<el-alert
title="使用说明"
type="info"
:closable="false"
show-icon>
<div slot="default">
<p><strong>功能说明</strong></p>
<ul>
<li>配置DSDeepSeekAI的提示词模板用于生成关键词和文案</li>
<li>模板存储在Redis中修改后立即生效</li>
<li>支持占位符%s 用于替换实际内容</li>
<li>删除模板将恢复为系统默认模板</li>
</ul>
<p><strong>模板类型</strong></p>
<ul>
<li><strong>关键词提取模板</strong>用于提取商品关键词占位符%s = 商品名称</li>
<li><strong>小红书文案模板</strong>用于生成小红书风格文案占位符%s = 商品名称%s = 价格信息%s = 关键词</li>
<li><strong>抖音文案模板</strong>用于生成抖音风格文案占位符%s = 商品名称%s = 价格信息%s = 关键词</li>
<li><strong>通用文案模板</strong>用于生成通用风格文案占位符%s = 商品名称%s = 价格信息%s = 关键词</li>
</ul>
</div>
</el-alert>
</div>
</el-collapse-transition>
<!-- 模板列表 -->
<div class="template-list">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane
v-for="(template, key) in templates"
:key="key"
:label="getTemplateLabel(key)"
:name="key">
<div class="template-editor">
<div class="template-info">
<el-alert
:title="template.description"
type="info"
:closable="false"
show-icon
style="margin-bottom: 15px;">
</el-alert>
<div class="template-status">
<el-tag :type="template.isDefault ? 'info' : 'success'" size="small">
{{ template.isDefault ? '使用默认模板' : '使用自定义模板' }}
</el-tag>
<el-button
v-if="!template.isDefault"
type="text"
size="small"
icon="el-icon-refresh-left"
@click="handleResetTemplate(key)"
style="margin-left: 10px;">
恢复默认
</el-button>
</div>
</div>
<el-input
v-model="template.template"
type="textarea"
:rows="12"
placeholder="请输入提示词模板..."
class="template-textarea">
</el-input>
<div class="template-actions">
<el-button
type="primary"
icon="el-icon-check"
@click="handleSaveTemplate(key)"
:loading="saving[key]">
保存模板
</el-button>
<el-button
icon="el-icon-refresh"
@click="handleLoadTemplate(key)">
重新加载
</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-card>
</div>
</template>
<script>
import { listPromptTemplates, getPromptTemplate, savePromptTemplate, deletePromptTemplate } from '@/api/jarvis/socialMediaPrompt'
export default {
name: 'SocialMediaPromptConfig',
data() {
return {
showHelp: false,
activeTab: 'keywords',
templates: {},
saving: {}
}
},
mounted() {
this.loadTemplates()
},
methods: {
/** 加载所有模板 */
async loadTemplates() {
try {
const res = await listPromptTemplates()
if (res.code === 200 && res.data) {
this.templates = res.data
// 初始化保存状态
Object.keys(this.templates).forEach(key => {
this.$set(this.saving, key, false)
})
} else {
this.$message.error(res.msg || '加载模板失败')
}
} catch (error) {
console.error('加载模板失败', error)
this.$message.error('加载模板失败:' + (error.message || '未知错误'))
}
},
/** 加载单个模板 */
async handleLoadTemplate(key) {
try {
const res = await getPromptTemplate(key)
if (res.code === 200 && res.data) {
this.$set(this.templates, key, res.data)
this.$message.success('重新加载成功')
} else {
this.$message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载模板失败', error)
this.$message.error('加载失败:' + (error.message || '未知错误'))
}
},
/** 保存模板 */
async handleSaveTemplate(key) {
const template = this.templates[key]
if (!template || !template.template || template.template.trim() === '') {
this.$message.warning('模板内容不能为空')
return
}
this.$set(this.saving, key, true)
try {
const res = await savePromptTemplate({
key: key,
template: template.template.trim()
})
if (res.code === 200) {
this.$message.success('保存成功!')
// 更新状态
this.$set(template, 'isDefault', false)
// 重新加载以确认
await this.handleLoadTemplate(key)
} else {
this.$message.error(res.msg || '保存失败')
}
} catch (error) {
console.error('保存模板失败', error)
this.$message.error('保存失败:' + (error.message || '未知错误'))
} finally {
this.$set(this.saving, key, false)
}
},
/** 恢复默认模板 */
async handleResetTemplate(key) {
try {
await this.$confirm('确定要恢复默认模板吗?自定义模板将被删除。', '提示', {
type: 'warning'
})
const res = await deletePromptTemplate(key)
if (res.code === 200) {
this.$message.success('已恢复默认模板')
// 重新加载
await this.handleLoadTemplate(key)
} else {
this.$message.error(res.msg || '恢复失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('恢复默认模板失败', error)
this.$message.error('恢复失败:' + (error.message || '未知错误'))
}
}
},
/** 获取模板标签 */
getTemplateLabel(key) {
const labels = {
'keywords': '关键词提取',
'content:xhs': '小红书文案',
'content:douyin': '抖音文案',
'content:both': '通用文案'
}
return labels[key] || key
}
}
}
</script>
<style scoped>
.prompt-config-container {
padding: 20px;
}
.card-title {
font-size: 18px;
font-weight: bold;
}
.help-section {
margin-bottom: 20px;
}
.help-section ul {
margin: 10px 0;
padding-left: 20px;
}
.help-section li {
margin: 5px 0;
}
.template-list {
margin-top: 20px;
}
.template-editor {
padding: 20px;
}
.template-info {
margin-bottom: 20px;
}
.template-status {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.template-textarea {
margin-bottom: 20px;
}
.template-textarea >>> .el-textarea__inner {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.template-actions {
text-align: right;
}
.template-actions .el-button {
margin-left: 10px;
}
</style>

View File

@@ -1,5 +1,8 @@
<template>
<div class="app-container">
<div>
<list-layout>
<!-- 搜索区域 -->
<template #search>
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="微信ID" prop="wxid">
<el-input v-model="queryParams.wxid" placeholder="请输入微信ID" clearable style="width: 240px" @keyup.enter.native="handleQuery" />
@@ -43,7 +46,10 @@
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</template>
<!-- 表格区域 -->
<template #table>
<el-table v-loading="loading" :data="superadminList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="微信ID" align="center" prop="wxid" min-width="120" />
@@ -69,6 +75,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="接收人" align="center" prop="touser" min-width="200" :show-overflow-tooltip="true" />
<el-table-column label="创建时间" align="center" prop="createdAt" min-width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createdAt) }}</span>
@@ -87,8 +94,13 @@
</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="title" :visible.sync="open" width="500px" append-to-body>
@@ -120,6 +132,12 @@
<el-radio :label="0">不参与统计</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="接收人" prop="touser">
<el-input v-model="form.touser" placeholder="请输入接收人列表多个用逗号分隔abc,bcd,efg" />
<div style="font-size: 12px; color: #909399; margin-top: 5px;">
企业微信用户ID多个用逗号分隔京粉推送时将发送给这些接收人
</div>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
@@ -146,6 +164,7 @@
{{ currentAdmin.isCount === 1 ? '参与统计' : '不参与统计' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="接收人" :span="2">{{ currentAdmin.touser || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ parseTime(currentAdmin.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ parseTime(currentAdmin.updatedAt) }}</el-descriptions-item>
</el-descriptions>
@@ -158,9 +177,13 @@
<script>
import { listSuperadmin, getSuperadmin, delSuperadmin, addSuperadmin, updateSuperadmin } from "@/api/system/superadmin";
import ListLayout from "@/components/ListLayout";
export default {
name: "Superadmin",
components: {
ListLayout
},
data() {
return {
// 遮罩层
@@ -240,7 +263,8 @@ export default {
appKey: null,
secretKey: null,
isActive: 1,
isCount: 1
isCount: 1,
touser: null
};
this.resetForm("form");
},

86
update-list-pages.md Normal file
View File

@@ -0,0 +1,86 @@
# 全局列表页面固定分页布局更新指南
## 已完成的页面
- ✅ 京粉订单列表 (`src/views/system/orderrows/index.vue`) - 已使用自定义布局
- ✅ 角色管理 (`src/views/system/role/index.vue`) - 已使用ListLayout组件
- ✅ 超级管理员 (`src/views/system/superadmin/index.vue`) - 已使用ListLayout组件
## 待更新的页面列表
以下是项目中其他需要更新为固定分页布局的列表页面:
### 系统管理模块
- `src/views/system/user/index.vue` - 用户管理(特殊布局,需要单独处理)
- `src/views/system/post/index.vue` - 岗位管理
- `src/views/system/notice/index.vue` - 通知公告
- `src/views/system/dict/index.vue` - 字典管理
- `src/views/system/dict/data.vue` - 字典数据
- `src/views/system/config/index.vue` - 参数设置
### 监控模块
- `src/views/monitor/operlog/index.vue` - 操作日志
- `src/views/monitor/job/log.vue` - 调度日志
- `src/views/monitor/logininfor/index.vue` - 登录日志
- `src/views/monitor/job/index.vue` - 定时任务
- `src/views/monitor/online/index.vue` - 在线用户
### 工具模块
- `src/views/tool/gen/index.vue` - 代码生成
### 业务模块
- `src/views/system/jdorder/orderList.vue` - 京东订单列表
- `src/views/system/xbmessage/index.vue` - 消息管理
- `src/views/system/favoriteProduct/index.vue` - 收藏商品
- `src/views/system/xbgroup/index.vue` - 群组管理
## 更新步骤
### 方法一使用ListLayout组件推荐
1. 在页面中导入ListLayout组件
```javascript
import ListLayout from "@/components/ListLayout";
export default {
components: {
ListLayout
},
// ...
}
```
2. 将页面结构改为:
```vue
<template>
<list-layout>
<!-- 搜索区域 -->
<template #search>
<!-- 原来的搜索表单和操作按钮 -->
</template>
<!-- 表格区域 -->
<template #table>
<!-- 原来的el-table -->
</template>
<!-- 分页区域 -->
<template #pagination>
<!-- 原来的pagination组件 -->
</template>
</list-layout>
</template>
```
### 方法二直接修改CSS适用于特殊布局
参考 `src/views/system/orderrows/index.vue` 的实现方式,直接修改页面样式。
## 注意事项
1. 用户管理页面使用了splitpanes布局需要特殊处理
2. 确保所有页面都保持原有的功能不变
3. 测试分页导航在不同数据量下的表现
4. 保持响应式设计在移动端的良好表现
## 测试要点
- [ ] 分页导航固定在页面底部
- [ ] 表格内容可以独立滚动
- [ ] 搜索区域固定在顶部
- [ ] 移动端显示正常
- [ ] 所有原有功能正常工作

View File

@@ -9,7 +9,12 @@ const CompressionPlugin = require('compression-webpack-plugin')
const name = process.env.VUE_APP_TITLE || 'Jarvis' // 网页标题
const baseUrl = process.env.VUE_APP_BASE_API || 'http://127.0.0.1:30313' // 后端接口
// 后端接口地址(仅用于开发环境代理)
// 生产环境应该使用相对路径(如 /dev-api通过nginx代理
// 开发环境可以使用相对路径通过devServer代理或绝对URL
const baseUrl = process.env.VUE_APP_BASE_API || (process.env.NODE_ENV === 'production' ? '/dev-api' : 'http://127.0.0.1:30313')
// 开发环境代理路径如果未设置VUE_APP_BASE_API使用默认代理路径
const devApiPath = process.env.VUE_APP_BASE_API || '/dev-api'
const port = process.env.port || process.env.npm_config_port || 80 // 端口
@@ -35,16 +40,18 @@ module.exports = {
open: true,
proxy: {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {
target: baseUrl,
// 如果VUE_APP_BASE_API是相对路径(如/dev-api则使用代理
// 如果是绝对URL则直接使用该URL不配置代理
[devApiPath]: {
target: baseUrl.startsWith('http') ? baseUrl : 'http://127.0.0.1:30313',
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: ''
['^' + devApiPath]: ''
}
},
// springdoc proxy
'^/v3/api-docs/(.*)': {
target: baseUrl,
target: baseUrl.startsWith('http') ? baseUrl : 'http://127.0.0.1:30313',
changeOrigin: true
}
},

56
修复环境变量.txt Normal file
View File

@@ -0,0 +1,56 @@
请手动修改以下两个文件:
===========================================
文件1.env.development
===========================================
将以下内容:
VUE_APP_BASE_API = 'http://134.175.126.60:30313'
改为:
VUE_APP_BASE_API=/dev-api
完整文件内容应该是:
# 页面标题
VUE_APP_TITLE=Jarvis
# 开发环境配置
ENV=development
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES=true
# 开发环境使用代理路径通过vue.config.js的devServer代理到后端
VUE_APP_BASE_API=/dev-api
port=8888
===========================================
文件2.env.production
===========================================
将以下内容:
VUE_APP_BASE_API = '/jarvis-api'
改为:
VUE_APP_BASE_API=/dev-api
完整文件内容应该是:
# 页面标题
VUE_APP_TITLE=Jarvis
# 生产环境配置
ENV=production
# 生产环境使用相对路径通过nginx代理到后端
# 注意这里的路径必须与nginx配置中的 location /dev-api/ 保持一致
VUE_APP_BASE_API=/dev-api
port=8888
===========================================
重要提示:
===========================================
1. 注意等号两边不要有空格VUE_APP_BASE_API=/dev-api不是 VUE_APP_BASE_API = /dev-api
2. 不要使用引号(直接写 /dev-api不要写 '/dev-api' 或 "/dev-api"
3. 修改后必须重新打包npm run build:prod
4. 确保nginx配置中的 location /dev-api/ 与前端配置一致

View File

@@ -0,0 +1,20 @@
@echo off
chcp 65001 >nul
echo 正在创建环境变量配置文件...
echo # 开发环境配置 > .env.development
echo # 开发环境使用代理路径通过vue.config.js的devServer代理到后端 >> .env.development
echo VUE_APP_BASE_API=/dev-api >> .env.development
echo # 生产环境配置 > .env.production
echo # 生产环境使用相对路径通过nginx代理到后端 >> .env.production
echo VUE_APP_BASE_API=/dev-api >> .env.production
echo.
echo 环境变量文件创建完成!
echo .env.development - 开发环境配置
echo .env.production - 生产环境配置
echo.
echo 请重新打包项目npm run build:prod
pause

104
快速修复指南.md Normal file
View File

@@ -0,0 +1,104 @@
# 快速修复HTTPS访问后端接口问题
## 问题
访问 `https://jarvis.van333.cn` 时,前端请求仍然是 `http://134.175.126.60:30313/captchaImage`
## 立即执行以下步骤
### 步骤1创建环境变量文件
在项目根目录 `d:\code\ruoyi-vue\` 下,创建两个文件:
#### 文件1`.env.development`
内容:
```
VUE_APP_BASE_API=/dev-api
```
#### 文件2`.env.production`
内容:
```
VUE_APP_BASE_API=/dev-api
```
**创建方法Windows**
1. 在项目根目录打开命令行
2. 执行:
```cmd
echo VUE_APP_BASE_API=/dev-api > .env.development
echo VUE_APP_BASE_API=/dev-api > .env.production
```
或者手动创建:
1. 在项目根目录新建文件 `.env.development`
2. 在项目根目录新建文件 `.env.production`
3. 两个文件内容都是:`VUE_APP_BASE_API=/dev-api`
### 步骤2删除旧的打包文件如果存在
```cmd
cd d:\code\ruoyi-vue
rmdir /s /q dist
```
### 步骤3重新打包
```cmd
npm run build:prod
```
### 步骤4部署到服务器
将 `dist` 目录中的所有文件复制到服务器的 `/www/sites/jarvis.van333.cn/index/` 目录
### 步骤5确认Nginx配置
确保nginx配置文件中包含
```nginx
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;
}
```
### 步骤6重启Nginx
```bash
sudo nginx -t
sudo nginx -s reload
```
### 步骤7清除浏览器缓存并测试
1. 按 `Ctrl + Shift + Delete` 清除浏览器缓存
2. 或者按 `Ctrl + F5` 强制刷新
3. 打开开发者工具F12查看Network请求
4. 确认请求URL是 `https://jarvis.van333.cn/dev-api/captchaImage`
## 验证配置是否正确
打包完成后,检查打包后的文件:
```cmd
cd d:\code\ruoyi-vue\dist\static\js
findstr /s /i "134.175.126.60" *.js
```
如果找到包含该IP的文件说明环境变量未生效需要
1. 确认 `.env.production` 文件存在且内容正确
2. 删除 `dist` 目录后重新打包
3. 确认打包命令是 `npm run build:prod`(不是 `npm run build`
## 如果还是不行
检查 `vue.config.js` 第15行应该是
```javascript
const baseUrl = process.env.VUE_APP_BASE_API || (process.env.NODE_ENV === 'production' ? '/dev-api' : 'http://127.0.0.1:30313')
```
如果不对,说明配置未更新,需要重新保存 `vue.config.js` 文件。

294
移动端适配说明.md Normal file
View File

@@ -0,0 +1,294 @@
# 移动端适配说明
## 概述
本项目已全面适配移动端,提供了完整的移动端体验优化,包括:
1. **移动端专用组件**:卡片式列表、折叠搜索表单、底部导航等
2. **响应式布局**:自动适配不同屏幕尺寸
3. **触摸优化**:符合移动端交互规范
4. **性能优化**:流畅的滚动和动画效果
## 核心组件
### 1. MobileTable - 移动端表格组件
移动端自动显示为卡片式列表,桌面端显示为表格。
```vue
<template>
<mobile-table
:data="tableData"
:columns="columns"
:show-selection="true"
:show-actions="true"
:selected-rows="selectedRows"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@action="handleAction"
>
<!-- 自定义卡片头部 -->
<template #header="{ row }">
{{ row.name }}
</template>
<!-- 自定义列内容 -->
<template #column-status="{ row, value }">
<el-tag :type="value === '0' ? 'success' : 'danger'">
{{ value === '0' ? '正常' : '停用' }}
</el-tag>
</template>
<!-- 自定义操作按钮 -->
<template #actions="{ row }">
<el-dropdown-item command="edit" icon="el-icon-edit">编辑</el-dropdown-item>
<el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
</template>
</mobile-table>
</template>
<script>
export default {
data() {
return {
tableData: [],
columns: [
{ prop: 'name', label: '名称', mobile: true },
{ prop: 'status', label: '状态', dictType: 'sys_normal_disable' },
{ prop: 'createTime', label: '创建时间', formatter: this.parseTime }
],
selectedRows: []
}
},
methods: {
handleSelectionChange(row, selected) {
// 处理选择变化
},
handleRowClick(row) {
// 处理行点击
},
handleAction(command, row) {
// 处理操作
}
}
}
</script>
```
### 2. MobileSearchForm - 移动端搜索表单
移动端自动折叠为快速搜索,点击筛选按钮展开完整表单。
```vue
<template>
<mobile-search-form
:model="queryParams"
@search="handleSearch"
@reset="handleReset"
@quick-search="handleQuickSearch"
>
<el-form-item label="用户名称" prop="userName">
<el-input v-model="queryParams.userName" placeholder="请输入用户名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择">
<el-option label="正常" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
</mobile-search-form>
</template>
```
### 3. MobileButtonGroup - 移动端按钮组
移动端自动将按钮分组,主要按钮显示,其他按钮放入"更多"菜单。
```vue
<template>
<mobile-button-group
:buttons="buttons"
:primary-count="2"
:sticky="true"
@button-click="handleButtonClick"
/>
</template>
<script>
export default {
data() {
return {
buttons: [
{ key: 'add', label: '新增', type: 'primary', icon: 'el-icon-plus', handler: this.handleAdd },
{ key: 'edit', label: '修改', type: 'success', icon: 'el-icon-edit', handler: this.handleEdit },
{ key: 'delete', label: '删除', type: 'danger', icon: 'el-icon-delete', handler: this.handleDelete },
{ key: 'export', label: '导出', type: 'warning', icon: 'el-icon-download', handler: this.handleExport }
]
}
}
}
</script>
```
### 4. MobileBottomNav - 移动端底部导航
在移动端显示底部导航栏,方便快速切换页面。
```vue
<template>
<mobile-bottom-nav
:items="navItems"
:show="true"
@nav-click="handleNavClick"
/>
</template>
<script>
export default {
data() {
return {
navItems: [
{ path: '/index', label: '首页', icon: 'el-icon-s-home' },
{ path: '/system/user', label: '用户', icon: 'el-icon-user' },
{ path: '/system/menu', label: '菜单', icon: 'el-icon-menu' }
]
}
}
}
</script>
```
## 工具函数
### mobile.js
提供移动端检测和优化工具函数:
```javascript
import { isMobile, isIOS, initMobile } from '@/utils/mobile'
// 检测是否为移动端
if (isMobile()) {
// 移动端逻辑
}
// 初始化移动端优化
initMobile()
```
## 混入
### mobile.js mixin
提供移动端相关的计算属性和方法:
```vue
<script>
import mobileMixin from '@/mixins/mobile'
export default {
mixins: [mobileMixin],
methods: {
someMethod() {
// 使用移动端检测
if (this.$isMobile) {
// 移动端逻辑
}
// 使用移动端提示
this.$mobileToast('操作成功', 'success')
}
}
}
</script>
```
## 样式优化
### 响应式断点
- **移动端**`max-width: 768px`
- **平板**`769px - 1024px`
- **桌面端**`min-width: 1025px`
### 主要优化
1. **触摸目标**:所有可点击元素最小 44px × 44px
2. **字体大小**:输入框字体 16px 防止 iOS 自动缩放
3. **间距优化**:移动端使用更紧凑的间距
4. **圆角优化**使用更大的圆角值8px-12px
5. **阴影优化**:使用更柔和的阴影效果
## 使用示例
### 完整列表页面示例
```vue
<template>
<div class="app-container">
<!-- 搜索表单 -->
<mobile-search-form
:model="queryParams"
@search="getList"
@reset="resetQuery"
>
<el-form-item label="用户名称" prop="userName">
<el-input v-model="queryParams.userName" placeholder="请输入用户名称" />
</el-form-item>
</mobile-search-form>
<!-- 按钮组 -->
<mobile-button-group
:buttons="buttons"
:primary-count="2"
/>
<!-- 表格/卡片列表 -->
<mobile-table
:data="dataList"
:columns="columns"
:show-selection="true"
:selected-rows="selectedRows"
@selection-change="handleSelectionChange"
>
<template #column-status="{ value }">
<dict-tag :options="dict.type.sys_normal_disable" :value="value" />
</template>
</mobile-table>
<!-- 分页 -->
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
```
## 注意事项
1. **组件注册**:移动端组件需要在使用前注册或导入
2. **字典数据**MobileTable 组件需要字典数据支持,确保字典已加载
3. **路由配置**:底部导航需要配置正确的路由路径
4. **性能优化**:大数据量时建议使用虚拟滚动或分页加载
## 浏览器支持
- iOS Safari 12+
- Android Chrome 70+
- 微信内置浏览器
- 其他现代移动浏览器
## 更新日志
### v1.0.0
- 初始版本
- 支持移动端卡片式列表
- 支持移动端折叠搜索
- 支持移动端底部导航
- 完整的响应式适配

89
配置说明.md Normal file
View File

@@ -0,0 +1,89 @@
# 解决HTTPS访问后端接口问题
## 问题现象
访问 `https://jarvis.van333.cn` 时,前端请求仍然是 `http://134.175.126.60:30313/captchaImage`,导致混合内容错误。
## 原因分析
1. **缺少环境变量配置文件**`.env.production` 文件不存在,打包时使用了默认值
2. **打包后的代码包含硬编码IP**如果之前打包时使用了IP地址需要重新打包
## 解决步骤
### 1. 创建环境变量文件
请在项目根目录手动创建以下两个文件:
#### `.env.development`(开发环境)
```
# 开发环境配置
# 开发环境使用代理路径通过vue.config.js的devServer代理到后端
VUE_APP_BASE_API=/dev-api
```
#### `.env.production`(生产环境)
```
# 生产环境配置
# 生产环境使用相对路径通过nginx代理到后端
VUE_APP_BASE_API=/dev-api
```
**或者运行批处理文件:**
```
双击运行:创建环境变量文件.bat
```
### 2. 重新打包项目
```bash
npm run build:prod
```
### 3. 部署到服务器
`dist` 目录中的文件部署到 `/www/sites/jarvis.van333.cn/index/`
### 4. 配置Nginx
确保使用 `nginx-https.conf` 中的配置,关键部分:
```nginx
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;
}
```
### 5. 重启Nginx
```bash
sudo nginx -t
sudo nginx -s reload
```
## 验证
1. **清除浏览器缓存**强制刷新Ctrl+F5
2. **打开开发者工具**F12查看Network标签
3. **确认API请求**应该是:
-`https://jarvis.van333.cn/dev-api/captchaImage`
- ❌ 不是 `http://134.175.126.60:30313/captchaImage`
## 注意事项
1. **必须重新打包**:修改环境变量后,必须重新运行 `npm run build:prod`
2. **清除浏览器缓存**:旧的打包文件可能被浏览器缓存
3. **检查nginx配置**:确保 `/dev-api/` 路径的代理配置正确
4. **检查后端服务**:确保后端服务运行在 `127.0.0.1:30313`
## 如果仍然有问题
1. 检查打包后的 `dist/static/js/` 目录中的JS文件搜索是否包含 `134.175.126.60`
2. 如果找到,说明打包时环境变量未生效,检查:
- `.env.production` 文件是否存在
- 文件内容是否正确(`VUE_APP_BASE_API=/dev-api`
- 是否在正确的目录(项目根目录)
- 重新打包前是否删除了旧的 `dist` 目录