118 Commits

Author SHA1 Message Date
mzl92 9c49da6cc5 feat(stocking): 编辑备货单支持车辆附件上传与回显
明细行备注下方增加附件上传(同新建页),进入编辑时经
populateFromDetail 回显已有附件,可增可删。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 11:23:37 +08:00
mzl92 2b925a1d37 feat(stocking): 查看备货单详情展示每辆车附件
OrderDetailContent 车辆明细展开行备注下方列出附件
(点击新窗口打开),备货单与终版查看弹窗共用生效;
兼容数组与历史字符串格式。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 11:17:39 +08:00
mzl92 d695281424 feat(stocking): 佣金不再参与利润计算
calcProfit 新车/二手车两个分支的公式移除 commission 减项,
佣金录入与提交保持不变。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 11:04:45 +08:00
mzl92 2c6a2dafec feat: 客户详情内容图片支持点击放大查看
desc_collect 富文本中的图片(如WhatsApp导入截图)点击后用
el-image-viewer 全屏放大(支持滚轮缩放/旋转,点击遮罩关闭),
图片加 cursor:zoom-in 提示可点击。
覆盖:业务端客户列表/公海、管理后台客户/公海/回收站/审核,共6处。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 09:27:24 +08:00
mzl92 6ab4fc9b9a feat(stocking): 取消预估退税自动计算,改为纯手动输入
移除 watch 中的 (官方价-优惠)*11.5% 自动写入与手动编辑跟踪
(manualTaxRefundRows/prevTaxRefundValues/calcTaxRefund),
保留手动输入后利润联动重算;placeholder 改为请输入。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 18:03:20 +08:00
mzl92 77ac384d29 fix(stocking): 修复编辑回填因 attachment 格式导致的车辆明细不渲染
后端 show 返回的 attachment 为 JSON 字符串时,.map 抛 TypeError
中断整个 items 回填。增加 normalizeAttachment 统一兼容
数组/字符串/空值。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 17:47:48 +08:00
mzl92 0dfc9a8bcc fix(stocking): 车辆附件上传位置移至备注下方
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 15:15:21 +08:00
mzl92 6e23f83e78 docs: 后端对接说明-备货单车辆附件上传
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 14:51:46 +08:00
mzl92 b3f6d591b5 fix: 补充缺失的 dayjs 依赖
项目代码多处 import dayjs 但 package.json 未声明,导致 vue-tsc
类型检查与 build 失败。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 14:41:28 +08:00
mzl92 bd1524f4f1 feat(stocking): 添加备货单车辆明细行内附件上传
每辆车行增加「上传附件」按钮与文件标签列表,附件随车辆
提交(attachment 字段)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 14:41:28 +08:00
mzl92 f809bf05e3 feat(stocking): useStockingForm 支持车辆明细附件上传
ItemRow 增加 attachment_files,mappedItems 映射 attachment,
新增车辆附件上传/删除方法并暴露。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 14:38:54 +08:00
mzl92 6f80f5126c docs: 备货单车辆明细附件上传实现计划
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 14:36:44 +08:00
mzl92 b5d414d9af docs: 备货单车辆明细附件上传设计
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 14:34:28 +08:00
mzl92 a275a98671 feat(stocking): 终版列表为特权用户增加「全部/我的」视图切换
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 11:40:34 +08:00
mzl92 1d9ef001c1 feat(stocking): 备货单列表为特权用户增加「全部/我的」视图切换
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 11:40:19 +08:00
mzl92 aa23d0949b docs: 备货单/终版列表视图切换实现计划
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 11:37:34 +08:00
mzl92 76baee2fe5 docs: 备货单/终版列表「全部/我的」视图切换设计
为 user_id=73 在 stocking_list 与 final_list 增加「全部/我的」切换,
view_scope 语义化传参,后端按 token+73 鉴权,默认「我的」。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 11:34:33 +08:00
浅念 786082305d 物品领用生成二维码 2026-03-13 16:50:21 +08:00
浅念 ce0e9cd377 登录系统id 固定传1 2026-02-13 11:25:22 +08:00
浅念 75d44a8eae 添加物品列表 及审核页面 2026-02-12 09:42:41 +08:00
浅念 cdece9fd79 抓取车辆(按关键词或url抓取) 2026-02-05 09:37:37 +08:00
浅念 7c5d0127cc 调整优化 审批和备货单 退税 2026-01-28 11:53:41 +08:00
浅念 a11d090273 终版备货单 添加重新创建 调整 备货单需求页面显示 2025-12-25 13:54:10 +08:00
浅念 9b2776e533 预估退税 添加 自定义输入 重新计算利润 2025-12-10 15:31:41 +08:00
浅念 437f5bb567 询单权限 调整 2025-12-10 11:01:24 +08:00
浅念 15e86e0f4f 备货单 打印预览 添加 内勤查看数据 2025-12-09 15:02:43 +08:00
浅念 65b2cb1037 调整权限 去掉系统选择 2025-12-08 11:53:02 +08:00
浅念 2648c49a3b 备货单 添加 合计到岸价 货币切换 2025-12-03 11:06:56 +08:00
浅念 3b396dc762 回收站 添加 删除原因 显示 2025-11-20 10:29:02 +08:00
浅念 0ed507efd5 清关目的港加筛选搜索 2025-11-19 09:31:16 +08:00
浅念 4281cb3740 车辆采集修改 客户删除 添加删除原因 2025-11-18 11:03:19 +08:00
浅念 7638eaf80f 备货单 二手车添加vin 调整备货单显示 文件预览问题处理 2025-11-11 17:27:07 +08:00
浅念 963a95732b 终版 计划收款信息 调整成可添加修改 2025-11-10 15:54:59 +08:00
浅念 6e2cd44f55 认领添加菜单权限 2025-11-04 16:21:04 +08:00
浅念 0e8cec4af0 添加 内勤 付款 财务收款 及统计 车辆加车系关键字采集 2025-11-04 15:51:38 +08:00
浅念 6ae4fd872d 调整预览历史图错误问题 管理端备货单添加删除 2025-11-04 11:06:00 +08:00
浅念 58a6732f1b 备货单 添加预览 跟进列表分页 审批记录显示问题处理 2025-11-03 19:01:08 +08:00
浅念 b527715b3a crm 端统计页 表格分页无效调整 2025-11-03 10:25:32 +08:00
浅念 0f7aae3c72 日志接口传参 2025-10-24 16:35:33 +08:00
浅念 8e2dd0600f 日志接口传参 2025-10-24 16:31:30 +08:00
浅念 926823df32 日志添加关键词搜索 2025-10-24 16:28:37 +08:00
浅念 37e75014d0 修复打印预览里面 币种 默认不显示情况 2025-10-24 16:00:14 +08:00
浅念 a70ad7a65c 审批历史查看详情错误修复 2025-10-24 15:24:26 +08:00
浅念 631ca4ec30 备货单 添加 加价 将内勤专属 移到打印查看里面 2025-10-24 14:45:15 +08:00
浅念 cd1bbca4a6 审核添加 合同显示 及日志 2025-10-23 15:23:23 +08:00
浅念 2ba64b94b0 添加内勤专用 查看 2025-10-23 10:05:13 +08:00
浅念 3aa1b0f79b 利润 退税 计算保留两位小数 2025-10-22 13:53:25 +08:00
浅念 ff0fb83bd0 10.17沟通调整
计划收款信息增加复制操作
上传合同调整为:上传合同、上传PI、上传水单(均为必传,且支持多文件操作与下载)
2025-10-22 11:52:20 +08:00
浅念 222d07d483 报关价可以修改货币 港口要可以筛选,还有账户 2025-10-16 09:54:39 +08:00
浅念 16abb6364c 判断是否有签名换到长链接推送 然后添加 报关价 预估退税计算 2025-10-14 14:43:09 +08:00
浅念 153bb37d10 去掉终版 车架号 必须都填写才能提交的限制 2025-10-13 09:44:45 +08:00
浅念 0b89bade20 添加 采集 修改车辆信息页面 备货单页面调整 审核菜单徽标 2025-10-12 17:14:32 +08:00
浅念 dee800f90f 客户导出 加筛选条件 2025-10-09 18:07:35 +08:00
浅念 9925e2e2e4 Squashed commit of the following:
commit 90df51097f
Author: 浅念 <email@sample.com>
Date:   Wed Sep 24 18:50:35 2025 +0800

    备货单 采购单

commit 7b1fdf3883
Author: 浅念 <email@sample.com>
Date:   Wed Sep 24 11:15:31 2025 +0800

    渠道筛选

commit 1c81d3108c
Author: 浅念 <email@sample.com>
Date:   Wed Sep 24 10:50:43 2025 +0800

    客户列表 添加渠道筛选

commit 2b877e9b4d
Author: 浅念 <email@sample.com>
Date:   Mon Sep 22 10:20:46 2025 +0800

    去掉编码显示

commit bee80af5af
Author: 浅念 <email@sample.com>
Date:   Fri Sep 19 16:43:30 2025 +0800

    港口添加 英文

commit 045ce9289c
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 15:17:30 2025 +0800

    审批历史 和代办审批

commit c5ec4bb5e1
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 11:31:22 2025 +0800

    修改kpi

commit 9e260ddcd8
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 09:49:33 2025 +0800

    修改 kpi

commit 6feb3ace75
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 09:42:44 2025 +0800

    kpi 统计

commit ec257a246d
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 17:04:35 2025 +0800

    修改 弹框表格

commit fa764852ab
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 15:54:35 2025 +0800

    添加 账户简称

commit 65f7f4a123
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 15:10:28 2025 +0800

    品牌加热门  修改 仪表盘接口

commit 922cfec81d
Author: 浅念 <email@sample.com>
Date:   Tue Sep 16 15:57:17 2025 +0800

    调整备货单  crm 端页面

commit 5c8df36f12
Author: 浅念 <email@sample.com>
Date:   Mon Sep 15 17:14:38 2025 +0800

    调整备货单 及各页面名称

commit 5503a14408
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 18:10:03 2025 +0800

    账户添加公司

commit 3439b45d9a
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 17:53:26 2025 +0800

    修改终版备货单

commit 045d13eb7b
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 17:49:07 2025 +0800

    修改备货单 等页面

commit da690dfa06
Author: 浅念 <email@sample.com>
Date:   Thu Sep 11 17:44:37 2025 +0800

    修改 备货单 详情

commit 853e9591b3
Author: 浅念 <email@sample.com>
Date:   Wed Sep 10 18:11:39 2025 +0800

    修改 备货单页面

commit 9052bd1027
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 20:53:30 2025 +0800

    备货单  审核功能

commit 079d9babb8
Merge: ebd7479 91d810b
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 14:51:43 2025 +0800

    Merge branch 'feature/crm_pc' of http://219.157.255.213:25311/xinyouhe/art-design into feature/crm_vehicle

commit ebd7479573
Merge: 64e53b6 459d306
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 13:57:28 2025 +0800

    Merge branch 'feature/crm_pc' of http://219.157.255.213:25311/xinyouhe/art-design into feature/crm_vehicle

commit 64e53b6ee9
Author: 浅念 <email@sample.com>
Date:   Mon Sep 8 19:09:55 2025 +0800

    备货单 加 审批流程

commit 9bd7c89cb4
Author: 浅念 <email@sample.com>
Date:   Thu Sep 4 15:09:17 2025 +0800

    车辆信息,备货单  等页面

# Conflicts:
#	src/api/adminApi.ts
#	src/api/crmApi.ts
#	src/components/custom/OrderDetailContent.vue
#	src/composables/useOrderDetailDialog.ts
#	src/composables/useStockingForm.ts
#	src/views/admin/approval/stocking_auth.vue
#	src/views/admin/approval/stocking_auth_detil.vue
#	src/views/admin/stock/final_list_admin.vue
#	src/views/admin/stock/stocking_list_admin.vue
#	src/views/admin/user/index.vue
#	src/views/admin/vehicle/color.vue
#	src/views/admin/vehicle/modification.vue
#	src/views/admin/vehicle/port.vue
#	src/views/admin/vehicle/vehicle_list.vue
#	src/views/crmpc/approval/stocking_auth.vue
#	src/views/crmpc/stocking/Add.vue
#	src/views/crmpc/stocking/final_add.vue
#	src/views/crmpc/stocking/final_edit.vue
#	src/views/crmpc/stocking/final_list.vue
#	src/views/crmpc/stocking/stocking_edit.vue
#	src/views/crmpc/stocking/stocking_list.vue
#	src/views/crmpc/vehicle/color.vue
#	src/views/crmpc/vehicle/modification.vue
#	src/views/crmpc/vehicle/vehicle_list.vue
2025-09-24 19:06:26 +08:00
浅念 4f18a1b8bf Squashed commit of the following:
commit c5ec4bb5e1
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 11:31:22 2025 +0800

    修改kpi

commit 9e260ddcd8
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 09:49:33 2025 +0800

    修改 kpi

commit 6feb3ace75
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 09:42:44 2025 +0800

    kpi 统计

commit ec257a246d
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 17:04:35 2025 +0800

    修改 弹框表格

commit fa764852ab
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 15:54:35 2025 +0800

    添加 账户简称

commit 65f7f4a123
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 15:10:28 2025 +0800

    品牌加热门  修改 仪表盘接口

commit 922cfec81d
Author: 浅念 <email@sample.com>
Date:   Tue Sep 16 15:57:17 2025 +0800

    调整备货单  crm 端页面

commit 5c8df36f12
Author: 浅念 <email@sample.com>
Date:   Mon Sep 15 17:14:38 2025 +0800

    调整备货单 及各页面名称

commit 5503a14408
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 18:10:03 2025 +0800

    账户添加公司

commit 3439b45d9a
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 17:53:26 2025 +0800

    修改终版备货单

commit 045d13eb7b
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 17:49:07 2025 +0800

    修改备货单 等页面

commit da690dfa06
Author: 浅念 <email@sample.com>
Date:   Thu Sep 11 17:44:37 2025 +0800

    修改 备货单 详情

commit 853e9591b3
Author: 浅念 <email@sample.com>
Date:   Wed Sep 10 18:11:39 2025 +0800

    修改 备货单页面

commit 9052bd1027
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 20:53:30 2025 +0800

    备货单  审核功能

commit 079d9babb8
Merge: ebd7479 91d810b
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 14:51:43 2025 +0800

    Merge branch 'feature/crm_pc' of http://219.157.255.213:25311/xinyouhe/art-design into feature/crm_vehicle

commit ebd7479573
Merge: 64e53b6 459d306
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 13:57:28 2025 +0800

    Merge branch 'feature/crm_pc' of http://219.157.255.213:25311/xinyouhe/art-design into feature/crm_vehicle

commit 64e53b6ee9
Author: 浅念 <email@sample.com>
Date:   Mon Sep 8 19:09:55 2025 +0800

    备货单 加 审批流程

commit 9bd7c89cb4
Author: 浅念 <email@sample.com>
Date:   Thu Sep 4 15:09:17 2025 +0800

    车辆信息,备货单  等页面

# Conflicts:
#	src/views/admin/kpi/kpi_annual.vue
2025-09-18 11:34:11 +08:00
浅念 a514bf4009 Squashed commit of the following:
commit 9e260ddcd8
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 09:49:33 2025 +0800

    修改 kpi

commit 6feb3ace75
Author: 浅念 <email@sample.com>
Date:   Thu Sep 18 09:42:44 2025 +0800

    kpi 统计

commit ec257a246d
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 17:04:35 2025 +0800

    修改 弹框表格

commit fa764852ab
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 15:54:35 2025 +0800

    添加 账户简称

commit 65f7f4a123
Author: 浅念 <email@sample.com>
Date:   Wed Sep 17 15:10:28 2025 +0800

    品牌加热门  修改 仪表盘接口

commit 922cfec81d
Author: 浅念 <email@sample.com>
Date:   Tue Sep 16 15:57:17 2025 +0800

    调整备货单  crm 端页面

commit 5c8df36f12
Author: 浅念 <email@sample.com>
Date:   Mon Sep 15 17:14:38 2025 +0800

    调整备货单 及各页面名称

commit 5503a14408
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 18:10:03 2025 +0800

    账户添加公司

commit 3439b45d9a
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 17:53:26 2025 +0800

    修改终版备货单

commit 045d13eb7b
Author: 浅念 <email@sample.com>
Date:   Fri Sep 12 17:49:07 2025 +0800

    修改备货单 等页面

commit da690dfa06
Author: 浅念 <email@sample.com>
Date:   Thu Sep 11 17:44:37 2025 +0800

    修改 备货单 详情

commit 853e9591b3
Author: 浅念 <email@sample.com>
Date:   Wed Sep 10 18:11:39 2025 +0800

    修改 备货单页面

commit 9052bd1027
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 20:53:30 2025 +0800

    备货单  审核功能

commit 079d9babb8
Merge: ebd7479 91d810b
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 14:51:43 2025 +0800

    Merge branch 'feature/crm_pc' of http://219.157.255.213:25311/xinyouhe/art-design into feature/crm_vehicle

commit ebd7479573
Merge: 64e53b6 459d306
Author: 浅念 <email@sample.com>
Date:   Tue Sep 9 13:57:28 2025 +0800

    Merge branch 'feature/crm_pc' of http://219.157.255.213:25311/xinyouhe/art-design into feature/crm_vehicle

commit 64e53b6ee9
Author: 浅念 <email@sample.com>
Date:   Mon Sep 8 19:09:55 2025 +0800

    备货单 加 审批流程

commit 9bd7c89cb4
Author: 浅念 <email@sample.com>
Date:   Thu Sep 4 15:09:17 2025 +0800

    车辆信息,备货单  等页面
2025-09-18 09:55:10 +08:00
浅念 91d810b33a 添加公司搜索 2025-09-09 14:51:23 +08:00
浅念 459d306504 用户添加 公司搜索 2025-09-09 13:56:43 +08:00
浅念 8a0d4fc516 自定义跟进时间 处理 2025-09-04 14:38:09 +08:00
浅念 58f7d56f40 工单去掉 移动端不显示限制 2025-09-03 15:13:10 +08:00
浅念 a91e1d7820 将编号 后移 2025-09-02 14:13:15 +08:00
浅念 a9f8c5c4dc 客户重复通知 2025-08-27 09:20:56 +08:00
浅念 aefd43f79d 修改 通知详情样式 和 即时通讯 2025-08-26 09:55:42 +08:00
浅念 1bd9d163ec 通知 列表富文本内容显示 2025-08-25 15:31:14 +08:00
浅念 be6093e57d crm 客户添加自定义列 跟进添加图片粘贴上传 2025-08-25 13:34:13 +08:00
浅念 d83246ba8c 显示编号 2025-08-22 18:33:58 +08:00
浅念 329acaf68d 通知板块 及 功能调整 2025-08-22 18:27:26 +08:00
浅念 b319944007 调整联系方式字段为电话 列表中多个联系方式纵向排列显示 会增加公司信息的自定义输入框 排序搜索 等功能 2025-08-21 11:12:12 +08:00
浅念 70d26f0829 1.0.3 的相关功能 2025-08-19 16:18:15 +08:00
浅念 0e84bedb67 v1.0.2 更新内容 2025-08-15 16:31:55 +08:00
浅念 f5deb7b8f7 添加菜单 权限 等页面 2025-08-13 19:27:49 +08:00
浅念 e797cb24de 添加工单附件 及样式 2025-08-11 19:24:24 +08:00
chenlan 619311dfbe 添加crm 归属客户 2025-08-08 22:13:25 +08:00
浅念 b402e522bf 询单权限校验 2025-08-07 10:51:17 +08:00
浅念 f950f665a9 添加 询单图片预览 2025-08-07 10:10:45 +08:00
浅念 8deecc8197 修改用户页面 询单 2025-08-06 18:36:42 +08:00
浅念 3e24d1905f 添加用户页面 客户页面接口 等 2025-08-05 18:25:32 +08:00
浅念 7542d80e38 修改 仪表盘页面样式 2025-08-04 18:05:07 +08:00
浅念 e5548f95e4 修改询单字段 仪表盘页面 及添加管理端客户页面 2025-08-04 14:50:55 +08:00
浅念 d30ea5b7bf 添加echarts 插件 2025-08-04 14:48:58 +08:00
浅念 0e4160c5ca 仪表盘接口 2025-08-01 18:27:22 +08:00
浅念 bd23288acd 修改 仪表盘 2025-08-01 18:26:46 +08:00
浅念 6bfb940881 客户列表 添加公海详情展示 2025-08-01 10:01:35 +08:00
浅念 b50f650565 修改 客户标签样式 个人信息添加邮箱 修复关闭全部页面404 2025-07-31 17:26:36 +08:00
浅念 ea1dbb7af4 修改公海和询单页面 2025-07-30 18:50:31 +08:00
浅念 1bfbb95bd9 修改样式和添加用户数据显示 2025-07-30 17:23:09 +08:00
浅念 a9f5f76b35 设置默认页面 2025-07-30 15:06:03 +08:00
浅念 3ea9d3d5df 修改飞书登录 公司id 系统id 获取不到问题 客户及公海添加用户信息显示 2025-07-30 11:46:31 +08:00
浅念 0cef82279f 修改工单 客户等页面 2025-07-29 16:42:36 +08:00
浅念 695ebbcc71 修改默认值 2025-07-25 17:50:04 +08:00
浅念 ffc194a1c1 飞书授权添加系统码 及登录进入页面路由 2025-07-25 17:05:33 +08:00
浅念 48b95e252f 回收站添加占位 2025-07-25 15:48:49 +08:00
浅念 382011d592 修改登录页样式公海数据 个人信息默认图 2025-07-25 15:11:50 +08:00
浅念 0f790269db 修改字段名称 2025-07-25 09:16:55 +08:00
浅念 f18cfdb69e 调整代码格式 2025-07-24 17:28:51 +08:00
浅念 858147fbbd 修改token刷新 及修改询单详情展示 2025-07-24 10:17:22 +08:00
浅念 c333b907e6 对接询单接口 2025-07-23 18:22:56 +08:00
浅念 7979a31e36 修改代码格式 2025-07-23 14:40:02 +08:00
浅念 4a39387865 设置客户列表权限 2025-07-23 14:07:13 +08:00
浅念 fc4de02582 feat(router): 调整菜单 2025-07-22 17:08:13 +08:00
浅念 2ffe0c9753 feat(crmApi.ts): 调整结构 2025-07-22 17:04:16 +08:00
浅念 c3fb2c48d3 feat(router): 路由调整 2025-07-22 17:00:25 +08:00
浅念 c5b0a8265a chore: 添加询单页面及调整菜单 2025-07-22 16:54:06 +08:00
浅念 ca06c9bc46 询单页面及菜单 2025-07-22 16:35:43 +08:00
浅念 3b6de4c7fa Merge branch 'master' into feature/crm_pc 2025-07-22 09:22:20 +08:00
浅念 85e8b9b6b4 chore: 更新忽略文件配置 2025-07-22 09:20:50 +08:00
浅念 d3bb4ec6e5 Merge branch 'master' into feature/crm_pc
# Conflicts:
#	.gitignore
#	src/api/crmApi.ts
#	src/api/usersApi.ts
#	src/store/modules/user.ts
#	src/utils/http/index.ts
#	src/views/auth/login/index.vue
#	src/views/crmpc/customer/Show.vue
#	src/views/crmpc/customer/index.vue
2025-07-21 18:22:55 +08:00
浅念 d7ab6f1a11 修改忽略 2025-07-21 18:18:35 +08:00
浅念 c7d890809c 添加系统码 2025-07-21 18:12:16 +08:00
浅念 31cd18fade Merge branch 'master' into feature/crm_pc
# Conflicts:
#	.gitignore
2025-07-21 18:02:48 +08:00
浅念 2c9695fcd1 1 2025-07-21 18:02:18 +08:00
浅念 59c9c97c08 Merge branch 'master' into feature/crm_pc
# Conflicts:
#	.gitignore
2025-07-21 17:56:52 +08:00
浅念 c129009b23 忽略文件 2025-07-21 17:55:09 +08:00
浅念 d4b577c42e feat(crm): 添加相关图片 2025-07-21 13:03:56 +08:00
浅念 7c1766ec79 Merge branch 'develop'
# Conflicts:
#	src/App.vue
#	src/api/menuApi.ts
#	src/components/core/layouts/art-header-bar/index.vue
#	src/router/routes/asyncRoutes.ts
#	src/router/routesAlias.ts
#	src/typings/api.d.ts
#	src/views/system/user-center/index.vue
2025-07-21 11:44:09 +08:00
浅念 e4967760b7 Finish crm_pc 2025-07-21 11:25:49 +08:00
浅念 85102fcf32 crm 接口对接 2025-07-21 11:24:06 +08:00
浅念 2a82a7ba95 Merge branch 'develop' 2025-07-21 11:16:16 +08:00
189 changed files with 69561 additions and 9758 deletions
+2 -1
View File
@@ -309,6 +309,7 @@
"watchWithFilter": true,
"whenever": true,
"ElMessage": true,
"ElTag": true
"ElTag": true,
"ElMessageBox": true
}
}
+6 -4
View File
@@ -5,11 +5,13 @@ VITE_BASE_URL = /
# API 地址前缀
# VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default
# VITE_API_URL = http://219.157.255.213:25702
# VITE_API_URL = http://219.157.255.213:25703
# VITE_API_URL = https://mzl.dev.hnxinyouhe.com
# VITE_API_URL = https://fdesk.hnxinyouhe.com
VITE_API_URL = https://x-server.hnxinyouhe.com
VITE_API_URL = https://fdesk.hnxinyouhe.com
# VITE_API_URL = https://x-server.hnxinyouhe.com
# VITE_API_URL_WSS = wss://mzl.dev.hnxinyouhe.com
# VITE_API_URL_WSS = wss://fdesk.hnxinyouhe.com
VITE_API_URL_WSS = wss://x-server.hnxinyouhe.com
# Delete console
VITE_DROP_CONSOLE = false
+3 -1
View File
@@ -5,7 +5,9 @@ VITE_BASE_URL = /
# API 地址前缀
# VITE_API_URL = https://fdesk.hnxinyouhe.com
# VITE_API_URL = https://mzl.dev.hnxinyouhe.com
VITE_API_URL = https://x-server.hnxinyouhe.com
# VITE_API_URL_WSS = wss://fdesk.hnxinyouhe.com
VITE_API_URL_WSS = wss://x-server.hnxinyouhe.com
# Delete console
VITE_DROP_CONSOLE = true
+5
View File
@@ -3,4 +3,9 @@ node_modules
dist
dist-ssr
*.local
*.env
.cursorrules
src/types/components.d.ts
src/types/auto-imports.d.ts
.vscode
.auto-import.json
-1
View File
@@ -1 +0,0 @@
pnpm dlx commitlint --edit $1
-1
View File
@@ -1 +0,0 @@
pnpm run lint:lint-staged
@@ -0,0 +1,237 @@
# 备货单车辆明细附件上传 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 `Add.vue` 车辆明细每行增加行内附件上传,附件随车辆提交到 `stock_order_items.attachment`。
**Architecture:** 数据层改共用的 `useStockingForm.ts`:`ItemRow` 加 `attachment_files`(展示用 FileItem[])、`newItem` 初始化、`mappedItems` 映射成 `attachment`(`{url, original_name}` 数组,与 `contract` 实际格式一致)、新增上传/删除方法并暴露 `uploadHeaders`/`itemUploadAction`。UI 在 `Add.vue` 明细行加一个 `ElCol`:`el-upload` 按钮 + 已传文件 `el-tag` 列表。上传走现成 `/ticket/upload?scene=stock_orders`,无需改上传接口。
**Tech Stack:** Vue 3 `<script setup>`、TypeScript、element-plus 2.8、`useStockingForm` composable、`useUploadHeaders`(已导入)。
## Global Constraints
- **无前端测试框架**:验收 = `npm run build`(含 `vue-tsc --noEmit` 类型检查)通过 + `dev` 手动核对。
- **attachment 提交格式**:`{ url, original_name }` 对象数组(参照 `baseForm.contract` 实际代码 useStockingForm.ts:697-700),**不是** file_name 数组——这样回填/预览有 url 可用。
- `useUploadHeaders` 已在 useStockingForm.ts:6 导入、:155 取出 `uploadHeaders`,直接复用、暴露。
- 后端 `stock_order_items.attachment` 字段已加(DB 就绪);接口层需对接 `veh_list[].attachment` 的接收与返回。
- commit 规范:每任务一次 commit,`feat:` 开头。
## 文件结构
| 文件 | 责任 | 改动 |
|---|---|---|
| `src/composables/useStockingForm.ts` | 车辆表单数据/上传方法(Add/edit 共用) | Modify |
| `src/views/crmpc/stocking/Add.vue` | 添加备货单页,车辆明细行内附件 UI | Modify |
---
## Task 1: useStockingForm.ts 数据层 + 上传方法
**Files:**
- Modify: `src/composables/useStockingForm.ts`(`ItemRow` 接口 ~49、`newItem` ~218、上传方法区 ~706、`mappedItems` ~1234、`populateFromDetail` items 映射 ~974、`return` ~1515)
**Interfaces:**
- Consumes: `useUploadHeaders`(已导入)、`items: ref<ItemRow[]>`(已有)、`/ticket/upload` 返回 `{code, data:{original_name, file_url, file_name}}`。
- Produces: `ItemRow.attachment_files`;暴露 `uploadHeaders`/`itemUploadAction`/`beforeItemAttachmentUpload`/`handleItemAttachmentUpload`/`removeItemAttachment`。
- [ ] **Step 1: `ItemRow` 接口加字段**
在 `export interface ItemRow { ... }` 内(如 `remark: string` 之后)加:
```ts
attachment_files?: { name: string; url: string; file_name: string }[]
```
- [ ] **Step 2: `newItem()` 初始化**
在 `const newItem = (): ItemRow => ({ ... })` 对象内(如 `remark: ''` 之后)加:
```ts
attachment_files: [],
```
- [ ] **Step 3: 新增上传 action 与方法**
在 `handleContractUpload` 之后(约 line 706 后)插入:
```ts
// 车辆明细附件上传
const itemUploadAction = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=stock_orders`
const beforeItemAttachmentUpload = (file: File) => {
const isValidType = /\.(jpg|jpeg|png|gif|pdf|doc|docx|xls|xlsx|txt)$/i.test(file.name)
if (!isValidType) {
ElMessage.error('只能上传图片或文档文件!')
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
ElMessage.error('文件大小不能超过 10MB!')
return false
}
return true
}
const handleItemAttachmentUpload = (response: any, idx: number) => {
if (response.code === 200) {
const fileInfo = {
name: response.data.original_name || '附件',
url: response.data.file_url,
file_name: response.data.file_name
}
if (!items.value[idx].attachment_files) {
items.value[idx].attachment_files = []
}
items.value[idx].attachment_files!.push(fileInfo)
ElMessage.success('附件上传成功!')
} else {
ElMessage.error(response.message || '附件上传失败!')
}
}
const removeItemAttachment = (idx: number, fileIdx: number) => {
const list = items.value[idx].attachment_files
if (list) {
list.splice(fileIdx, 1)
ElMessage.success('附件已删除')
}
}
```
- [ ] **Step 4: `mappedItems` 映射 `attachment`**
在 `mappedItems` 的 `increase_price: wrapMoney(...)` 之后(约 line 1234 后、`}))` 之前)加:
```ts
attachment: (it.attachment_files || []).map((f) => ({
url: f.url,
original_name: f.name
}))
```
- [ ] **Step 5: `populateFromDetail` 回填**
在 `populateFromDetail` 中 `items.value = list.map((it: any) => ({ ... }))` 的映射对象内(与 `model_snapshot` 等同层)加,兼容后端两种返回格式:
```ts
attachment_files: (it.attachment || []).map((f: any) => ({
name: f.original_name || f.name || '附件',
url: f.url || f.file_url || '',
file_name: f.file_name || f.url || ''
}))
```
- [ ] **Step 6: `return` 暴露**
在 `return {`(约 line 1515)对象内加:
```ts
uploadHeaders,
itemUploadAction,
beforeItemAttachmentUpload,
handleItemAttachmentUpload,
removeItemAttachment,
```
- [ ] **Step 7: 类型检查(阶段性)**
Run: `npx vue-tsc --noEmit 2>&1 | grep -E "useStockingForm|error TS" | head`
Expected: 无 `useStockingForm.ts` 相关 error。(dayjs 等无关报错忽略)
- [ ] **Step 8: Commit**
```bash
git add src/composables/useStockingForm.ts
git commit -m "feat(stocking): useStockingForm 支持车辆明细附件上传
ItemRow 增加 attachment_files,mappedItems 映射 attachment,
新增车辆附件上传/删除方法。
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## Task 2: Add.vue 行内附件 UI
**Files:**
- Modify: `src/views/crmpc/stocking/Add.vue`(解构 ~1189-1211、车辆明细行 ~116+)
**Interfaces:**
- Consumes: Task 1 暴露的 `uploadHeaders`/`itemUploadAction`/`beforeItemAttachmentUpload`/`handleItemAttachmentUpload`/`removeItemAttachment`;`items`、`idx`(已有)。
- Produces: 每行附件上传交互。
- [ ] **Step 1: 解构新方法**
在 Add.vue 从 `useStockingForm(false)` 解构的块内(`chooseVehicle` 等同处,约 line 1206-1210)加:
```ts
uploadHeaders,
itemUploadAction,
beforeItemAttachmentUpload,
handleItemAttachmentUpload,
removeItemAttachment
```
- [ ] **Step 2: 明细行加「附件」ElCol**
在车辆明细 `v-for="(item, idx) in items"` 的行内(建议放在「里程」`ElCol` 之后,约 line 196 后)加:
```html
<ElCol :sm="12" :md="12" :lg="6" :xl="4">
<ElFormItem label="附件" label-width="85px">
<el-upload
:action="itemUploadAction"
:headers="uploadHeaders"
:show-file-list="false"
:before-upload="beforeItemAttachmentUpload"
:on-success="(res: any) => handleItemAttachmentUpload(res, idx)"
multiple
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
>
<el-button size="small" type="primary" plain>上传附件</el-button>
</el-upload>
<div
v-if="item.attachment_files && item.attachment_files.length"
style="margin-top: 4px"
>
<el-tag
v-for="(f, fIdx) in item.attachment_files"
:key="fIdx"
closable
@close="removeItemAttachment(idx, fIdx)"
style="margin: 2px 4px 2px 0"
>
{{ f.name }}
</el-tag>
</div>
</ElFormItem>
</ElCol>
```
- [ ] **Step 3: 类型检查 + 构建**
Run: `npm run build`
Expected: vue-tsc 无 error,vite build 产出 dist。
- [ ] **Step 4: 手动核对(dev)**
Run: `npm run dev`,进入添加备货单页。
核对:每辆车行有「上传附件」按钮;选文件上传后出现文件名 tag;tag × 可删;提交时 Network `veh_list[].attachment` 为 `{url, original_name}` 数组。
- [ ] **Step 5: Commit**
```bash
git add src/views/crmpc/stocking/Add.vue
git commit -m "feat(stocking): 添加备货单车辆明细行内附件上传
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 后端联调清单(交付后端)
- `stock_order_items.attachment` 字段已就绪。
- store/update:接收 `veh_list[].attachment`(`{url, original_name}` 对象数组),写入 `attachment`(json 序列化)。
- show/detail:返回 `items[].attachment`(同 `{url, original_name}` 结构),前端据此回显。
@@ -0,0 +1,272 @@
# 备货单 / 终版列表「全部 / 我的」视图切换 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 `stocking_list.vue` 与 `final_list.vue` 给 `userId === 73` 的特权用户增加「全部 / 我的」视图切换,切换时给列表接口传 `view_scope=all|self`。
**Architecture:** 纯前端 UI + 传参改动。新增本地状态 `viewScope`,工具栏渲染条件 `canViewAll = userStore.getUserInfo.userId === 73`;`getList` 把 `viewScope.value` 作为 `view_scope` 随请求发出;切换时重置页码并重新请求。是否返回全量数据由后端按 token 鉴权决定(后端改造见 spec 第 3.4 节,不在本计划内)。
**Tech Stack:** Vue 3 `<script setup>` + TypeScript、element-plus 2.8、Pinia(`useUserStore`)、`unplugin-auto-import`(`ref/reactive/computed/onMounted/watch` 自动导入,无需手写 import)。
## Global Constraints
- **无前端单元测试框架**:本项目 `package.json` 无 `test` 脚本。验收方式 = `lint` 通过 + 本地 `dev` 手动核对,不写自动化测试。
- **后端是前置依赖**:前端「全部」真正生效需后端 `GET /crm/stock/order/list` 支持 `view_scope` 参数(见 spec 3.4)。前端可先行独立完成 UI 与传参;完整功能联调待后端就绪。
- **安全边界在后端**:前端 `userId === 73` 仅控制按钮显隐,非授权依据。
- **代码风格**:遵循现有写法——`<script setup lang="ts">`、element-plus 组件、snake_case 请求参数。
- **commit 规范**:每任务一次 commit,信息以 `feat:` 开头。
## 文件结构
| 文件 | 责任 | 改动类型 |
|---|---|---|
| `src/views/crmpc/stocking/stocking_list.vue` | 备货单列表,加视图切换 + `view_scope` 传参 | Modify |
| `src/views/crmpc/stocking/final_list.vue` | 终版列表,同构改动 | Modify |
两文件改动完全同构,仅 `is_final`(0 vs 1)不同。不新建文件。
---
## Task 1: stocking_list.vue 增加视图切换
**Files:**
- Modify: `src/views/crmpc/stocking/stocking_list.vue`
- 模板工具栏(约 `line 9-22` 的 `toolbar-right`)
- `getList` 参数(约 `line 339-344`)
- script 状态(约 `line 280-281` `useAuth` 之后)
- script import(约 `line 264-274`)
**Interfaces:**
- Consumes: `useUserStore`(来自 `@/store/modules/user`),其 `getUserInfo: computed => Partial<{ userId: number }>`;`CrmService.getStockList(params)`(已存在)。
- Produces: 列表请求新增参数字段 `view_scope: 'all' | 'self'`。
- [ ] **Step 1: 添加 import**
在 `<script setup lang="ts">` 顶部 import 区(`import { useAuth } from '@/composables/useAuth'` 同处)增加一行:
```ts
import { useUserStore } from '@/store/modules/user'
```
- [ ] **Step 2: 添加状态与切换处理**
在 `const { hasAuth } = useAuth()`(约 line 281)之后插入:
```ts
const userStore = useUserStore()
// 仅 userId=73 的特权用户可切换「全部/我的」
const canViewAll = computed(() => userStore.getUserInfo.userId === 73)
const viewScope = ref<'all' | 'self'>('self')
const onScopeChange = () => {
pagination.currentPage = 1
getList()
}
```
说明:`computed` / `ref` 由 `unplugin-auto-import` 提供,无需 import。`getUserInfo.userId` 类型为 `number | undefined`,`undefined === 73` 为 `false`,判空安全。
- [ ] **Step 3: 修改 `getList` 请求参数**
定位 `getList` 内(约 line 339-344):
```ts
const params = {
current: currentPage,
size: pageSize,
is_final: 0, // 只获取备货单,非终版
keyword: keyword.value
}
```
改为(增加 `view_scope`):
```ts
const params = {
current: currentPage,
size: pageSize,
is_final: 0, // 只获取备货单,非终版
keyword: keyword.value,
view_scope: viewScope.value
}
```
- [ ] **Step 4: 工具栏添加切换控件**
定位模板 `toolbar-right`(约 line 9-21),在 `<el-input>` 之前插入 `el-radio-group`:
原:
```html
<div class="toolbar-right">
<el-input
v-model="keyword"
```
改为:
```html
<div class="toolbar-right">
<el-radio-group
v-if="canViewAll"
v-model="viewScope"
@change="onScopeChange"
style="margin-right: 12px"
>
<el-radio-button value="self">我的</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<el-input
v-model="keyword"
```
说明:用 `value`(element-plus 2.6+ 推荐)而非 `label` 作值,避免 2.8 的 label 弃用告警。文字走默认插槽。
- [ ] **Step 5: Lint 校验**
Run: `npm run fix -- src/views/crmpc/stocking/stocking_list.vue`
Expected: 无 error。若有,按提示修复(常见:prettier 格式)。
- [ ] **Step 6: 手动核对(前端独立部分)**
Run: `npm run dev`,浏览器打开备货单列表页。
核对项:
1. 用**非 73 账号**登录:工具栏**不出现**「全部/我的」控件,列表行为同改动前。
2. 打开 DevTools Network,看 `/crm/stock/order/list` 请求 Payload:无论是否 73,都应带上 `view_scope=self`(默认值)。
3. (需 73 登录态)用 73 账号登录:出现「全部/我的」,默认选中「我的」;点「全部」后 Network 请求带 `view_scope=all`,点回「我的」带 `view_scope=self`,且每次切换分页回到第 1 页。
4. 「全部」是否真返回全量数据,取决于后端是否已支持——后端未就绪时,切到「全部」数据可能不变,属预期。
- [ ] **Step 7: Commit**
```bash
git add src/views/crmpc/stocking/stocking_list.vue
git commit -m "feat(stocking): 备货单列表为特权用户增加「全部/我的」视图切换
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## Task 2: final_list.vue 增加视图切换(同构)
**Files:**
- Modify: `src/views/crmpc/stocking/final_list.vue`
- 模板工具栏(约 `line 7-19` 的 `toolbar-right`)
- `getList` 参数(约 `line 278-283`)
- script 状态(约 `line 229` `useAuth` 之后)
- script import(约 `line 206-216`)
**Interfaces:**
- Consumes: 同 Task 1(`useUserStore`、`CrmService.getStockList`)。
- Produces: 同 Task 1(`view_scope` 参数)。
- [ ] **Step 1: 添加 import**
在 `<script setup lang="ts">` 顶部 import 区增加:
```ts
import { useUserStore } from '@/store/modules/user'
```
- [ ] **Step 2: 添加状态与切换处理**
在 `const { hasAuth } = useAuth()`(约 line 229)之后插入:
```ts
const userStore = useUserStore()
// 仅 userId=73 的特权用户可切换「全部/我的」
const canViewAll = computed(() => userStore.getUserInfo.userId === 73)
const viewScope = ref<'all' | 'self'>('self')
const onScopeChange = () => {
pagination.currentPage = 1
getList()
}
```
- [ ] **Step 3: 修改 `getList` 请求参数**
定位 `getList` 内(约 line 278-283):
```ts
const params = {
current: currentPage,
size: pageSize,
is_final: 1, // 只获取备货单,非终版
keyword: keyword.value
}
```
改为:
```ts
const params = {
current: currentPage,
size: pageSize,
is_final: 1, // 终版
keyword: keyword.value,
view_scope: viewScope.value
}
```
(注:原代码注释「非终版」是笔误,此处顺手修正为「终版」。)
- [ ] **Step 4: 工具栏添加切换控件**
定位模板 `toolbar-right`(约 line 7-19),在 `<el-input>` 之前插入:
原:
```html
<div class="toolbar-right">
<el-input
v-model="keyword"
```
改为:
```html
<div class="toolbar-right">
<el-radio-group
v-if="canViewAll"
v-model="viewScope"
@change="onScopeChange"
style="margin-right: 12px"
>
<el-radio-button value="self">我的</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<el-input
v-model="keyword"
```
- [ ] **Step 5: Lint 校验**
Run: `npm run fix -- src/views/crmpc/stocking/final_list.vue`
Expected: 无 error。
- [ ] **Step 6: 手动核对(前端独立部分)**
Run: `npm run dev`,浏览器打开终版列表页。核对项同 Task 1 Step 6(注意此处是 `is_final=1` 的终版页)。
- [ ] **Step 7: Commit**
```bash
git add src/views/crmpc/stocking/final_list.vue
git commit -m "feat(stocking): 终版列表为特权用户增加「全部/我的」视图切换
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 后端联调清单(交付后端,不在本计划执行范围)
接口 `GET /crm/stock/order/list` 增加可选参数 `view_scope`:
| 请求 | 后端行为 |
|---|---|
| 不传 / `view_scope=self` | 按现状:依 token 用户过滤 |
| `view_scope=all` 且 token 用户 id ∈ {73} | 返回全量 |
| `view_scope=all` 且 token 用户 id ∉ {73} | 降级为 `self`(返回该用户自己的) |
要点:**只信 token,忽略前端任何用户标识**,防 IDOR。
@@ -0,0 +1,120 @@
# 备货单车辆明细附件上传 设计
- 日期:2026-08-06
- 分支:feature/crm_pc
- 涉及前端:`src/views/crmpc/stocking/Add.vue`、`src/composables/useStockingForm.ts`
- 涉及后端:`stock_order_items.attachment`(已加字段)、备货单 store/update/show 接口
## 1. 背景
添加备货单(`Add.vue`)时,车辆明细(`items`,每行一辆车)目前只能录入车型 / 颜色 / 价格等字段,**没有附件**。业务需要每辆车能上传若干附件(如该车相关文件),随车辆一起保存。
后端 `stock_order_items` 表已新增 `attachment` 字段(本次由后端同事完成)。
## 2. 需求
- 添加备货单时,车辆明细**每辆车**可上传**多个**附件。
- UI 为**行内紧凑**形态:每行一个「上传附件」入口,已传文件以文件名标签列出、可删除,不撑高行高。
- 附件随车辆数据一起提交,存入 `stock_order_items.attachment`。
- 字段名统一用 `attachment`,值为 `file_name` 字符串数组(与备货单级 `contract`/`pi_doc`/`transaction_slip` 的存储模式一致)。
## 3. 现状(复用基础)
- 车辆明细 `items` 由 `useStockingForm(false)` 提供,每行 `item` 是 `ItemRow`。
- 备货单级文件已有成熟模式:前端维护 `FileItem[]`(`{name, url, file_name}`,带 url 供预览/下载),提交时映射成 `file_name[]` 存入 `baseForm.contract` 等。
- 上传走通用接口 `${VITE_API_URL}/ticket/upload?scene=stock_orders`,header 带 token(`useUploadHeaders`),成功返回 `{ data: { original_name, file_url, file_name } }`。**此接口无需改动。**
- 现成组件 `MultiFileUpload`(120px 大拖拽区)适合整页文件区,不适合塞进行内——故行内用 `el-upload` 直接内联。
## 4. 设计
### 4.1 数据层 `useStockingForm.ts`
- `ItemRow` 类型增加 `attachment_files: { name: string; url: string; file_name: string }[]`。
- `newItem()` 初始化 `attachment_files: []`。
- 提交映射 `mappedItems` 增加:
```ts
attachment: (it.attachment_files || []).map((f) => f.file_name)
```
- 详情回填 `populateFromDetail`:从后端返回的 `it.attachment` 反解为 `attachment_files`(格式见 4.4,Add 为新建不走回填,但 composable 与 edit 共享,一并支持)。
- `copyRow` 已是 `JSON.parse(JSON.stringify(...))`,自动带上附件,无需改。
- 草稿 `pickDraftPayload` 已含 `items: toRaw(items.value)`,附件自然入草稿,无需改。
- 新增车辆附件上传/删除方法(仿现有 `handleContractUpload` 等):
- `handleItemAttachmentUpload(response: any, idx: number)`:校验 `response.code===200`,把 `{ name: data.original_name, url: data.file_url, file_name: data.file_name }` push 进 `items.value[idx].attachment_files`。
- `removeItemAttachment(idx: number, fileIdx: number)`:从 `items.value[idx].attachment_files` 删除指定项。
### 4.2 UI 层 `Add.vue`
在车辆明细 `v-for="(item, idx) in items"` 的行内末尾增加一个 `ElCol`「附件」:
```html
<ElCol :sm="12" :md="12" :lg="6" :xl="4">
<ElFormItem label="附件" label-width="85px">
<el-upload
:action="`${VITE_API_URL}/ticket/upload?scene=stock_orders`"
:headers="uploadHeaders"
:show-file-list="false"
:before-upload="(file) => beforeItemAttachmentUpload(file)"
:on-success="(res) => handleItemAttachmentUpload(res, idx)"
multiple
:accept="itemAttachmentAccept"
>
<el-button size="small" type="primary" plain>上传附件</el-button>
</el-upload>
<div class="item-attachment-tags" v-if="item.attachment_files?.length">
<el-tag
v-for="(f, fIdx) in item.attachment_files"
:key="fIdx"
closable
@close="removeItemAttachment(idx, fIdx)"
style="margin: 2px 4px 2px 0"
>
{{ f.name }}
</el-tag>
</div>
</ElFormItem>
</ElCol>
```
- `VITE_API_URL`、`uploadHeaders`(`useUploadHeaders`)、`beforeItemAttachmentUpload`(类型/大小校验,复用 `MultiFileUpload` 的 accept 与 10MB 限制)、`handleItemAttachmentUpload`、`removeItemAttachment` 均由 `useStockingForm` return,模板解构使用,与现有 `contractFiles` 等用法一致。
### 4.3 后端配合
- `stock_order_items.attachment` 字段已加(DB 就绪)。
- 接口层需对接:
- **store / update**:接收 `items[].attachment`(`file_name` 数组),写入 `stock_order_items.attachment`(按 json/序列化存储)。
- **show / detail**:返回 `items[].attachment`(`file_name` 数组,或含 `original_name`/`file_url` 的对象数组——见 4.4 约定)。
### 4.4 回填格式约定(需与后端对齐)
详情接口返回的 `items[].attachment` 有两种可能,择一与后端确认:
- **A**:`file_name` 字符串数组(如 `["xxx.pdf"]`)——前端回填时 `url` 需另行拼接,预览受限。
- **B(推荐)**:对象数组(如 `[{ original_name, file_url, file_name }]`,与 `/ticket/upload` 返回一致)——前端直接还原成 `attachment_files`,预览/下载可用。
实现(plan)阶段以前端实际请求详情接口的返回为准;若为 A,前端按现有 `populateFromDetail` 中文件字段的拼 url 方式处理。
## 5. 安全 / 限制
- 复用 `beforeUpload`:类型限制 `.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt`,单文件 ≤ 10MB。
- 上传 header 带 token(`useUploadHeaders`),鉴权由后端 `/ticket/upload` 既有机制处理。
- `attachment` 只存 `file_name`,不含敏感信息。
## 6. 改动清单
| 端 | 文件 | 改动 |
|---|---|---|
| 前端 | `src/composables/useStockingForm.ts` | `ItemRow` 加 `attachment_files`;`newItem` 初始化;`mappedItems` 映射 `attachment`;`populateFromDetail` 回填;新增 `handleItemAttachmentUpload`/`removeItemAttachment`/`beforeItemAttachmentUpload`/暴露 `uploadHeaders` |
| 前端 | `src/views/crmpc/stocking/Add.vue` | 车辆明细行末尾加「附件」`ElCol`(`el-upload` + 标签列表);解构新方法 |
| 后端 | store/update/show 接口 | 对接 `items[].attachment`(DB 字段已就绪) |
## 7. 验收标准
1. 添加备货单:每辆车行有「上传附件」按钮,点选多文件逐个上传成功后,文件名以标签列出。
2. 标签可点 × 删除;删除后提交不包含该文件。
3. 提交备货单,Network 里 `items[].attachment` 为对应 `file_name` 数组;后端 `stock_order_items.attachment` 落库正确。
4. (后端 show 返回后)进入编辑/详情,已存附件能回显为标签列表。
5. 超格式 / 超 10MB 文件被拦截并提示。
## 8. 未做项 / 后续
- 本次只改 `Add.vue` 模板;`stocking_edit.vue` 共用 `useStockingForm`,数据层已支持,如需在编辑页也展示附件上传,后续加同款 `ElCol` 即可。
- 终版(final_add/final_edit)是否需要同等能力,待业务确认。
@@ -0,0 +1,99 @@
# 备货单 / 终版列表「全部 / 我的」视图切换 设计
- 日期:2026-08-06
- 分支:feature/crm_pc
- 涉及页面:`src/views/crmpc/stocking/stocking_list.vue`、`src/views/crmpc/stocking/final_list.vue`
- 涉及接口:`GET /crm/stock/order/list`
## 1. 背景
备货单列表(`stocking_list`,`is_final=0`)与终版列表(`final_list`,`is_final=1`)当前共用同一个列表接口 `GET /crm/stock/order/list`。后端依据请求 token 中的用户 id 过滤数据,即每个登录用户只能看到自己作为业务员的备货单 / 终版。前端请求参数只有 `current / size / is_final / keyword`,无任何范围控制。
业务上存在一个固定特权用户(user_id = 73),需要在「看自己」之外,额外能查看「全公司所有备货单 / 终版」。
## 2. 需求
- 给 user_id = 73 这一个用户,在两个列表页增加「全部 / 我的」视图切换。
- 选「全部」时,该用户看到全公司数据;选「我的」时,行为同现状(只看自己)。
- 其他用户(非 73)完全不受影响:不显示切换控件,行为同现状。
- 默认视图为「我的」。
## 3. 设计
### 3.1 UI
- 在两个列表页的工具栏增加一组互斥切换控件「全部 / 我的」(`el-radio-group` button 样式,二选一)。
- 控件位置:工具栏右侧、搜索框左边,两个页面位置保持一致(`final_list` 现有 `toolbar-left` 被注释,不在那里放,统一放右侧,改动更小)。
- 渲染条件:`useUserStore().getUserInfo.userId === 73`。`getUserInfo` 是 `userStore.info` 的 computed,类型 `Partial<UserInfo>`,`userId` 可能为 `undefined`,`undefined === 73` 为 `false`,判断安全,无需额外判空。
### 3.2 前端逻辑
- 新增本地状态 `viewScope = ref<'all' | 'self'>('self')`,初值 `'self'`(即默认「我的」)。
- 切换控件绑定 `viewScope`;`@change` 时:
1. `pagination.currentPage = 1`(切回第一页,避免越界空页);
2. 调用 `getList()`。
- `getList()` 构造参数时,当 `userId === 73` 把 `viewScope.value` 作为 `view_scope` 一并传入:
- 「全部」→ `view_scope: 'all'`
- 「我的」→ `view_scope: 'self'`
- 非 73 用户:不渲染控件,`viewScope` 恒为 `'self'`,传 `view_scope: 'self'`(或不传,见 3.3)。为简单起见统一传,后端对非特权用户会忽略该值。
### 3.3 请求参数
列表请求参数由
```jsonc
{ "current": 1, "size": 10, "is_final": 0, "keyword": "" }
```
扩展为(73 用户)
```jsonc
{ "current": 1, "size": 10, "is_final": 0, "keyword": "", "view_scope": "all" }
```
- 参数名 `view_scope` 沿用现有 snake_case 风格(与 `is_final` / `keyword` 一致)。
- 取值为语义化的 `'all' | 'self'`,**不传用户 id**。
### 3.4 后端配合
`GET /crm/stock/order/list` 增加可选参数 `view_scope`,处理逻辑:
| 请求情况 | 后端行为 |
|---|---|
| 不传 / `view_scope=self` | 现状逻辑:按 token 用户过滤,返回自己的数据 |
| `view_scope=all` 且 token 用户 id ∈ {73} | 返回全公司全量数据 |
| `view_scope=all` 且 token 用户 id ∉ {73} | 降级:当作 `self` 处理,返回该用户自己的数据(忽略 `all`) |
后端「可看全部」白名单暂写死为 `{73}`,与前端判断一致。
### 3.5 默认值
73 用户打开页面,`viewScope` 初值 `'self'`,即默认「我的」。需看全部时手动切换。
## 4. 安全考量
- **后端只认 token**:是否返回全量数据,由后端依据 token 中的用户 id 判断,绝不信任前端传入的任何用户标识。前端 `userId` 存于 localStorage,可被篡改。
- 前端写死 `userId === 73` 仅用于**控件显隐**的便捷实现,不是安全边界。即便有人改前端强制显示按钮并传 `view_scope=all`,后端鉴权不通过仍只返回其自己的数据。
- 结论:语义化参数 + 后端 token 鉴权,可兜住水平越权(IDOR)风险。
## 5. 改动清单
| 端 | 文件 / 接口 | 改动 |
|---|---|---|
| 前端 | `stocking_list.vue` | 工具栏加「全部/我的」`el-radio-group`(仅 73 显示);`getList` 传 `view_scope`;切换重置页码并重新请求 |
| 前端 | `final_list.vue` | 同上(`is_final=1`) |
| 后端 | `GET /crm/stock/order/list` | 接收 `view_scope`;token 用户 ∈ {73} 且 `=all` 时返回全量,否则按现状过滤 |
## 6. 验收标准
1. 以非 73 账号登录两个列表页:不出现「全部/我的」控件,数据范围与改动前一致。
2. 以 73 账号登录:
- 默认显示「我的」选中态,列表为 73 自己的数据。
- 切到「全部」,列表变为全公司数据;切回「我的」恢复。
- 切换时分页重置到第 1 页。
3. 用任意账号在浏览器手动把请求改成 `view_scope=all`:后端只返回该账号自己的数据(非 73 不越权)。
## 7. 未做项 / 后续 TODO
- 前端 `userId === 73` 为硬编码,加人需改前端发版。后续建议:`getUserInfo` 下发权限标记(如 `stock@view_all` / `final_stock@view_all`),前端用现有 `hasAuth(...)` 控制显隐;后端白名单同步改为权限点判断。本次保留此扩展点,先不动。
- `view_scope` 选择不持久化(每次进页面回到默认「我的」)。
@@ -0,0 +1,104 @@
# 备货单车辆明细附件上传 — 后端对接说明
> 前端已完成:添加备货单页,每辆车行有「上传附件」入口。本文档仅说明后端需配合的部分。
## 1. 功能说明
备货单(`stock_orders`)的车辆明细(`stock_order_items`),**每辆车**需要支持上传若干附件(如该车相关单据)。附件随车辆一起保存、详情回显。
## 2. 数据库(已就绪)
`stock_order_items` 表已新增 `attachment` 字段。
- 字段名:`attachment`
- 类型:建议 `varchar` / `text` / `json`,存 **JSON 字符串**
- 含义:该车辆的附件列表
## 3. 字段格式约定
`attachment` 存「对象数组」的 JSON:
```json
[
{ "url": "https://xxx.com/file.pdf", "original_name": "装箱单.pdf" }
]
```
| 字段 | 类型 | 说明 |
|---|---|---|
| `url` | string | 文件访问地址(即 `/ticket/upload` 返回的 `file_url`) |
| `original_name` | string | 原始文件名 |
- 每辆车 0~N 个附件;无附件时为空数组 `[]` 或 `null`。
> **重要**:此格式与备货单级 `contract` 字段**完全一致**(`contract` 也是 `[{url, original_name}]`)。后端可直接复用现有处理 `contract` 的读写逻辑,区别仅在于:`attachment` 挂在 `veh_list` 每一项、落到 `stock_order_items` 表。
## 4. 需要修改的接口
均在前端调用路径 `/crm/stock/order/` 下。
### 4.1 新增 `POST /crm/stock/order/store`
- 请求体 `veh_list` 每一项现在多一个 `attachment` 字段(`[{url, original_name}]`)。
- 解析 `veh_list` 入库时,把每项 `attachment` 写入对应 `stock_order_items` 行的 `attachment` 字段(JSON 序列化)。
### 4.2 编辑 `POST /crm/stock/order/update`
- 同 `store`。
### 4.3 详情 `GET /crm/stock/order/show`
- 返回的 `veh_list` 每一项需带上 `attachment`(`[{url, original_name}]`)。
### 4.4 详情 `GET /crm/stock/order/detail`
- 同 `show`,返回 `veh_list` 每一项带 `attachment`。
## 5. 请求 / 响应示例
**提交(store / update)payload:**
```jsonc
{
// ...备货单级字段(含 contract / pi_doc / transaction_slip)
"veh_list": [
{
"sku_id": "123",
"model_snapshot": "丰田 卡罗拉",
// ...其他车辆字段
"attachment": [
{ "url": "https://xxx/a.pdf", "original_name": "装箱单.pdf" }
]
}
],
"account_list": [],
"approval_status": 0
}
```
**详情(show / detail)响应:**
```jsonc
{
"veh_list": [
{
"sku_id": "123",
"model_snapshot": "丰田 卡罗拉",
"attachment": [
{ "url": "https://xxx/a.pdf", "original_name": "装箱单.pdf" }
]
}
]
}
```
## 6. 上传接口(无需改动)
`/ticket/upload`(通用文件上传)保持原样。前端已用它上传文件,拿到 `{ file_url, file_name, original_name }`,再自行拼成 `{ url, original_name }` 存进 `attachment`。**后端无需在上传接口做任何业务关联。**
## 7. 验收标准
1. 前端添加备货单、每辆车上传附件 → 提交 → `stock_order_items.attachment` 正确落库(JSON 数组)。
2. 调 `show` / `detail`,返回的 `veh_list` 每项 `attachment` 与落库内容一致。
3. (编辑页后续加 UI 后)编辑备货单时,已存附件可回显、可增删。
## 8. 备注
- 本次前端只在「添加备货单」页加了上传 UI;编辑页(`stocking_edit`)共用同一套提交/回填逻辑,数据层已支持 `attachment`,编辑页 UI 后续补,**不影响后端接口契约**。
- 终版(final)备货单如需同等能力,接口字段一致,可一并支持。
+34
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/echarts": "^4.9.22",
"@vue/reactivity": "^3.4.35",
"@vueuse/core": "^11.0.0",
"@wangeditor/editor": "^5.1.23",
@@ -16,6 +17,7 @@
"axios": "^1.7.5",
"crypto-js": "^4.2.0",
"echarts": "^5.4.0",
"echarts-gl": "^2.0.9",
"element-plus": "^2.8.0",
"file-saver": "^2.0.5",
"highlight.js": "^11.10.0",
@@ -2982,6 +2984,15 @@
"@types/node": "*"
}
},
"node_modules/@types/echarts": {
"version": "4.9.22",
"resolved": "https://registry.npmjs.org/@types/echarts/-/echarts-4.9.22.tgz",
"integrity": "sha512-7Fo6XdWpoi8jxkwP7BARUOM7riq8bMhmsCtSG8gzUcJmFhLo387tihoBYS/y5j7jl3PENT5RxeWZdN9RiwO7HQ==",
"license": "MIT",
"dependencies": {
"@types/zrender": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3138,6 +3149,12 @@
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
},
"node_modules/@types/zrender": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/zrender/-/zrender-4.0.6.tgz",
"integrity": "sha512-1jZ9bJn2BsfmYFPBHtl5o3uV+ILejAtGrDcYSpT4qaVKEI/0YY+arw3XHU04Ebd8Nca3SQ7uNcLaqiL+tTFVMg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz",
@@ -5103,6 +5120,11 @@
"consola": "^3.2.3"
}
},
"node_modules/claygl": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
"integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -6399,6 +6421,18 @@
"zrender": "5.6.1"
}
},
"node_modules/echarts-gl": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz",
"integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
"dependencies": {
"claygl": "^1.2.1",
"zrender": "^5.1.1"
},
"peerDependencies": {
"echarts": "^5.1.2"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.170",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz",
+7
View File
@@ -20,6 +20,10 @@
}
},
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix --quiet",
"prettier --write"
],
"*.{js,ts,mjs,mts,tsx}": [
"eslint --fix",
"prettier --write"
@@ -48,13 +52,16 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/echarts": "^4.9.22",
"@vue/reactivity": "^3.4.35",
"@vueuse/core": "^11.0.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "next",
"axios": "^1.7.5",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.21",
"echarts": "^5.4.0",
"echarts-gl": "^2.0.9",
"element-plus": "^2.8.0",
"file-saver": "^2.0.5",
"highlight.js": "^11.10.0",
+1895 -5387
View File
File diff suppressed because it is too large Load Diff
+206 -2
View File
@@ -12,16 +12,22 @@
import { UserService } from './api/usersApi'
import { setThemeTransitionClass } from './utils/theme/animation'
import { checkStorageCompatibility } from './utils/storage'
import { mittBus } from '@/utils/sys'
import appLogo from '@/assets/img/common/logox.png'
const router = useRouter()
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
const { language, accessToken } = storeToRefs(userStore)
function setCompanyFromUrl() {
const userStore = useUserStore()
const params = new URLSearchParams(window.location.search)
const companyid = params.get('corp_id')
const companyabbr = params.get('corp_abbr')
// const systemid = params.get('system_id')
// const system_code = params.get('system_code')
if (companyid) {
userStore.setCompany(companyid, companyabbr || '')
// userStore.setSystems(systemid || '', system_code || '')
// 你也可以加 localStorage/sessionStorage 备份
}
}
@@ -29,22 +35,160 @@
zh: zh,
en: en
}
const isSupported = ref(false)
const permissionStatus = ref('default')
// 检查浏览器支持情况
const checkSupport = () => {
if ('Notification' in window) {
isSupported.value = true
console.log('浏览器支持原生Notification API')
} else {
isSupported.value = false
console.log('⚠️ 浏览器不支持原生Notification API')
}
}
// 请求通知权限
const requestPermission = async () => {
if (!('Notification' in window)) {
console.log('浏览器不支持原生Notification API')
return
}
try {
const permission = await Notification.requestPermission()
permissionStatus.value = permission
if (permission === 'granted') {
console.log('用户已授予通知权限')
} else if (permission === 'denied') {
console.log('用户拒绝了通知权限')
} else {
console.log('用户暂时未授予通知权限')
}
} catch (error) {
console.log(`请求权限时出错:`)
}
}
// 显示基本通知(支持自定义标题与内容)
const showBasicNotification = (
title: string = '基本通知',
body: string = '这是一个使用浏览器原生API的系统通知'
) => {
if (!('Notification' in window)) {
ElMessage({
message: '浏览器不支持原生Notification API',
type: 'warning'
})
return
}
if (Notification.permission !== 'granted') {
ElMessage({
message: '请先授予通知权限',
type: 'warning'
})
requestPermission()
return
}
try {
const iconUrl = appLogo || '/favicon.ico'
const notification = new Notification(title, {
body,
requireInteraction: true,
icon: iconUrl
})
notification.onclick = () => {
notification.close()
}
} catch (error) {}
}
onBeforeMount(() => {
setThemeTransitionClass(true)
setCompanyFromUrl()
useUserStore().restoreTokenTimer()
})
// 页面刷新时重置WebSocket重连次数,同时清理事件监听
onBeforeUnmount(() => {
import('@/utils/sys/wsClient').then(({ resetWebSocketReconnect }) => {
resetWebSocketReconnect()
})
mittBus.off('ws:message')
})
const handleWsMessageNotification = (data: any) => {
if (data && data.event === 'notify.receive') {
const notice = data?.data?.notice_data
const title = notice?.message?.title || '新通知'
const rawContent = notice?.message?.content || ''
const body = typeof rawContent === 'string' ? rawContent.replace(/<[^>]+>/g, '') : ''
showBasicNotification(title, body)
}
}
onMounted(() => {
// 检查存储兼容性
checkStorageCompatibility()
checkSupport()
requestPermission()
// 提升暗黑主题下页面刷新视觉体验
setThemeTransitionClass(false)
// 系统升级
systemUpgrade()
// 获取用户信息
getUserInfo()
// 初始化 WebSocket
// showEventNotification()
if (accessToken.value) {
import('@/utils/sys/wsClient').then(
({ initWebSocket, resetWebSocketReconnect, getWebSocketStatus }) => {
// 页面刷新时重置重连次数
resetWebSocketReconnect()
initWebSocket(accessToken.value)
// 添加调试功能,可以在控制台查看WebSocket状态
// @ts-ignore
window.getWebSocketStatus = getWebSocketStatus
// console.log('WebSocket状态查看: window.getWebSocketStatus()')
}
)
}
// 监听ws消息,用于系统级通知
mittBus.on('ws:message', handleWsMessageNotification)
// 初始化菜单徽标系统(全局初始化,避免重复)
import('@/composables/useMenuBadge').then(({ useMenuBadge }) => {
const { initBadgeSystem } = useMenuBadge()
initBadgeSystem()
})
// 加载菜单徽标测试工具(仅开发环境)
if (import.meta.env.DEV) {
import('@/utils/menuBadgeTest')
}
})
// 登录后,accessToken 变更时立即初始化 WebSocket(无需刷新)
watch(accessToken, async (token) => {
if (token) {
const { initWebSocket, getWebSocketStatus } = await import('@/utils/sys/wsClient')
const status = getWebSocketStatus?.()
// 避免重复初始化:已连接或正在连接时不再初始化
if (
!status?.readyState ||
(status.readyState !== WebSocket.OPEN && status.readyState !== WebSocket.CONNECTING)
) {
initWebSocket(token)
}
// 重新初始化徽标系统(用户重新登录时)
import('@/composables/useMenuBadge').then(({ useMenuBadge }) => {
const { initBadgeSystem } = useMenuBadge()
initBadgeSystem()
})
} else {
// 退出登录时,关闭并重置
const { closeWebSocket, resetWebSocketReconnect } = await import('@/utils/sys/wsClient')
closeWebSocket()
resetWebSocketReconnect()
}
})
// 获取用户信息
@@ -59,3 +203,63 @@
}
}
</script>
<style>
.boxshadow {
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 10, 118, 0.1);
background: var(--art-main-bg-color);
padding: 10px 20px;
margin-bottom: 10px;
}
.border {
width: 6px;
height: 22px;
border-radius: 3px;
background: #3471FF;
display: inline-block;
vertical-align: bottom;
}
.el-dialog {
position: relative;
overflow: hidden;
}
.el-dialog::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(180deg, #E4ECFF 0%, #FFFFFF 50%);
pointer-events: none;
}
.el-dialog__header,
.el-dialog__body,
.el-dialog__footer {
background: transparent !important;
position: relative;
z-index: inherit;
}
.el-dialog__header {
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding-right: 40px;
}
.el-dialog__title {
flex: 1;
text-align: center;
font-size: 18px;
font-weight: bold;
}
.el-dialog__headerbtn {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
}
.item-row-wrap {
margin-bottom: 12px;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
import request from '@/utils/http'
export class invitemService {
// 物品列表
static getinvitemlist(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/inv/item/list',
params
})
}
// 添加物品
static invitemadd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inv/item/store',
params
})
}
// 修改物品
static invitemupdate(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inv/item/update',
params
})
}
// 删除物品
static invitemdelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inv/item/destroy',
params
})
}
// 借用审批相关接口
static getBorrowList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/inv/requisition/list',
params
})
}
// 借用申请详情
static getBorrowDetail(params: { id: number }) {
return request.get<any>({
url: '/admin/inv/requisition/show',
params
})
}
// 借用审批
static borrowApproval(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inv/requisition/approval',
params
})
}
// 归还审批列表
static getReturnList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/mobile/inv/requisition/return/list',
params
})
}
// 归还申请详情
static getReturnDetail(params: { id: number }) {
return request.get<any>({
url: '/admin/inv/requisition/return/show',
params
})
}
// 确认归还
static returnconfirm(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inv/requisition/return/confirm',
params
})
}
// 生成二维码
static getQrCode(params: { id: number; qr_claim_limit?: number }) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inv/item/qr-code',
params
})
}
// 删除二维码
static deleteQrCode(params: { id: number }) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inv/item/qr-code/delete',
params
})
}
// 管理员带领
static proxyClaim(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/requisition/proxy-claim',
params
})
}
// 获取库存变化记录
static getStockLogList(params: {
item_id: number
current?: number
size?: number
change_type?: number
}) {
return request.get<Api.Common.CommonListData>({
url: '/admin/stock-log/list',
params
})
}
// 获取扫码记录
static getQrClaimsList(params: {
item_id: number
current?: number
size?: number
claim_typeid?: number
start_time?: string
end_time?: string
}) {
return request.get<Api.Common.CommonListData>({
url: '/admin/requisition/qr-claims',
params
})
}
}
+35
View File
@@ -0,0 +1,35 @@
import request from '@/utils/http'
export class dashboardService {
//卡片统计
static officeclerkcard() {
return request.get<any>({
url: '/admin/dashboard/officeclerk/card'
})
}
//待审批
static officeclerkpending() {
return request.get<any>({
url: '/admin/dashboard/officeclerk/approval'
})
}
//业务员备货单统计
static salestockingcard() {
return request.get<any>({
url: '/admin/dashboard/officeclerk/curve'
})
}
//待认领
static officeclerkunclaimed(params: any) {
return request.get<any>({
url: '/admin/dashboard/officeclerk/claim',
params
})
}
//财务仪表盘统计
static officeclerkchart() {
return request.get<any>({
url: '/admin/fin/statistics'
})
}
}
+95
View File
@@ -0,0 +1,95 @@
import request from '@/utils/http'
export class financeService {
// 收款记录-列表
static getReceiptList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/receipt/list',
params
})
}
// 收款记录-详情
static getReceiptDetail(params: any) {
return request.get<any>({
url: '/admin/receipt/show',
params
})
}
// 收款记录-添加
static addReceipt(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/receipt/store',
params
})
}
// 收款记录-更新
static updateReceipt(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/receipt/update',
params
})
}
// 收款记录-删除
static deleteReceipt(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/receipt/destroy',
params
})
}
//付款申请-列表
static getPaymentList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/payment/list',
params
})
}
//付款申请-详情
static getPaymentDetail(params: any) {
return request.get<any>({
url: '/admin/payment/show',
params
})
}
//付款申请-添加
static addPayment(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/payment/store',
params
})
}
//付款申请-更新
static updatePayment(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/payment/update',
params
})
}
//付款申请-删除
static deletePayment(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/payment/delete',
params
})
}
// 绑定付款记录到收款记录
static bindPaymentRecord(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/receipt/payment',
params
})
}
//收款记录历史
static getReceiptHistory(params: any) {
return request.get<any>({
url: '/admin/receipt/his/list',
params
})
}
//确认收款
static confirmReceipt(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/receipt/credit',
params
})
}
}
+109
View File
@@ -0,0 +1,109 @@
import request from '@/utils/http'
export class kpiService {
// 月度-业绩 列表
static getmonthlylist(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/targets/monthly/list',
params
})
}
// 月度-业绩 添加
static addmonthly(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/targets/monthly/store',
params
})
}
// 月度-业绩 删除
static deletemonthly(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/targets/monthly/destroy',
params
})
}
// 月度-业绩 修改
static updatemonthly(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/targets/monthly/update',
params
})
}
// 月度-业绩 详情
static detailmonthly(params: any) {
return request.get<any>({
url: '/admin/targets/monthly/show',
params
})
}
//年度-业绩 列表
static getyearlylist(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/targets/annual/list',
params
})
}
// 年度-业绩 添加
static addyearly(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/targets/annual/store',
params
})
}
// 年度-业绩 删除
static deleteyearly(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/targets/annual/destroy',
params
})
}
// 年度-业绩 详情
static detailyearly(params: any) {
return request.get<any>({
url: '/admin/targets/annual/show',
params
})
}
// 年度-业绩 修改
static updateyearly(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/targets/annual/update',
params
})
}
//年度-公司目标完成率
static statisticstotal(params: any) {
return request.get<any>({
url: '/admin/targets/statistics/total',
params
})
}
//年度-个人销售数量排名
static statisticsranking(params: any) {
return request.get<any>({
url: '/admin/targets/statistics/ranking',
params
})
}
//年度-业务KPI分月完成数量表
static statisticsmonthly(params: any) {
return request.get<any>({
url: '/admin/targets/statistics/user/month',
params
})
}
//年度-个人年度目标完成度(百分比)
static statisticspercentage(params: any) {
return request.get<any>({
url: '/admin/targets/statistics/user/progressh',
params
})
}
//年度-个人累积完成数量(辆)
static statisticscumulative(params: any) {
return request.get<any>({
url: '/admin/targets/statistics/user/achieved',
params
})
}
}
+46
View File
@@ -0,0 +1,46 @@
import request from '@/utils/http'
export class logService {
// 更新日志-列表
static getChangelogList(params:any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/update/log/list',
params
})
}
// 更新日志-添加
static addChangelog(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/update/log/store',
params
})
}
// 更新日志-删除
static deleteChangelog(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/update/log/destroy',
params
})
}
// 更新日志-详情
static detailChangelog(params: any) {
return request.get<any>({
url: '/admin/update/log/show',
params
})
}
// 更新日志-修改
static updateChangelog(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/update/log/update',
params
})
}
//操作日志-列表
static getOperationLogList(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/op/log/list',
params
})
}
}
+46
View File
@@ -0,0 +1,46 @@
import request from '@/utils/http'
export class purchaseService {
// 采购单预置
static purchaseorderpredata(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/purchase/order/predata',
params
})
}
// 采购单列表
static purchaseorderlist(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/purchase/order/list',
params
})
}
// 采购单添加
static purchaseorderadd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/purchase/order/store',
params
})
}
// 采购单更新
static purchaseorderupdate(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/purchase/order/update',
params
})
}
// 采购单删除
static purchaseorderdelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/purchase/order/destroy',
params
})
}
// 采购单详情
static purchaseorderdetail(params: any) {
return request.get<any>({
url: '/admin/purchase/order/show',
params
})
}
}
+32
View File
@@ -0,0 +1,32 @@
import request from '@/utils/http'
export class userConService {
//分组列表
static getGroupList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/user/group/list',
params
})
}
//添加分组
static addGroup(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/group/store',
data: params
})
}
//编辑分组
static editGroup(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/group/update',
data: params
})
}
//删除分组
static deleteGroup(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/group/destroy',
data: params
})
}
}
+336
View File
@@ -0,0 +1,336 @@
import request from '@/utils/http'
export class manageService {
//仪表盘 总
static statisticswhole() {
return request.get<any>({
// url: '/admin/inquiry/statistics/whole'
url: 'admin/crm/statistics/whole'
})
}
//各渠道统计
static channelcount() {
return request.get<any>({
// url: '/admin/inquiry/statistics/channel/count'
url: '/admin/crm/statistics/channel/count'
})
}
//各国家统计
static countrycount() {
return request.get<any>({
// url: '/admin/inquiry/statistics/country/count'
url: '/admin/crm/statistics/country/count'
})
}
//跟单人统计
static trackcount() {
return request.get<any>({
// url: '/admin/inquiry/statistics/track/count'
url: '/admin/crm/statistics/track/count'
})
}
//每日录单统计
static daycount() {
return request.get<any>({
// url: '/admin/inquiry/statistics/day/count'
url: '/admin/crm/statistics/day/count'
})
}
//每日录单统计
static inquirycount(params: any) {
return request.get<any>({
// url: '/admin/inquiry/statistics/inquiry/count',
url: '/admin/crm/statistics/customer/count',
params
})
}
//询单列表
static getinquiryList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/inquiry/list',
params
})
}
// 获取详情
static inquirydetail(params: any) {
return request.get<any>({
url: '/admin/inquiry/show',
params
})
}
//添加询单
static inquiryAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inquiry/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//修改询单
static inquiryedit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inquiry/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//删除询单
static inquirydelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/inquiry/destroy',
params
})
}
//分配询单
static inquiryallocation(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/inquiry/allocation',
params
// showErrorMessage: false // 不显示错误消息
})
}
//重新分配询单
static inquiryreallocation(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/inquiry/reallocation',
params
// showErrorMessage: false // 不显示错误消息
})
}
//发布询单
static inquiryrelease(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/inquiry/release',
params
// showErrorMessage: false // 不显示错误消息
})
}
//获取用户下拉框
static presetusers() {
return request.get<any>({
url: '/common/preset/users'
})
}
//crm 客户管理
//客户列表
static getcrmCustomerList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListDatad>({
url: '/admin/crm/customer/list',
params
})
}
//客户导出
static crmCustomerExport(params: any) {
return request.get<any>({
url: '/admin/crm/customer/export',
params,
responseType: 'blob' // 设置响应类型为 blob
})
}
//客户审核
static crmCustomerAudit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/crm/customer/audit',
params
// showErrorMessage: false // 不显示错误消息
})
}
//客户转移
static crmCustomerTransfer(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/crm/customer/transfer',
params
// showErrorMessage: false // 不显示错误消息
})
}
//用户列表
static getUserList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/user/list',
params
})
}
//用户编辑
static userEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//用户设置角色
static userSetRole(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/authorize',
params
// showErrorMessage: false // 不显示错误消息
})
}
//用户菜单权限设置
static userMenuAuth(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/set/resources',
params
// showErrorMessage: false // 不显示错误消息
})
}
//清除缓存
static clearCache() {
return request.get<any>({
url: '/common/preset/cache/clear'
})
}
//公海客户分配
static crmCustomerAllocation(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/crm/customer/cede',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公海客户跟进记录
static crmCustomerFollow(params: any) {
return request.get<any>({
url: '/admin/crm/customer/follow',
params: params
})
}
//公海客户删除
static crmCustomerDelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/crm/customer/delete',
params
// showErrorMessage: false // 不显示错误消息
})
}
//回收站 恢复
static customerRestore(params: any) {
return request.get<any>({
url: '/admin/crm/customer/restore',
params
})
}
//备货单审核列表
static getstocklist(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/approval/list',
params
})
}
//备货单审核
static stockapproval(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/approval/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
// 审核更新
static stockapprovalupdate(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/approval/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//审核详情
static stockapprovaldetail(params: any) {
return request.get<any>({
url: '/admin/approval/show',
params
})
}
//审核删除
static stockapprovaldelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/approval/destroy',
params
})
}
//审批记录
static stockapprovalrecord(params: any) {
return request.get<any>({
url: '/admin/approval/records/todo',
params
})
}
//审批通过/拒绝
static stockapprovalpass(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/approval/records/action',
params
})
}
//审批记录
static stockapprovallogs(params: any) {
return request.get<any>({
url: '/admin/approval/records/logs',
params
})
}
//备货单
static getStockList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/stock/order/list',
params
})
}
//添加备货单
static addStock(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/stock/order/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//详情备货单
static getStockDetail(params: any) {
return request.get<any>({
url: '/admin/stock/order/show',
params
})
}
//备货单查看
static checkStock(params: any) {
return request.get<any>({
url: '/admin/stock/order/detail',
params
})
}
//备货单删除
static delectStock(params: any) {
return request.get<any>({
url: '/admin/stock/order/destroy',
params
})
}
//打印
static printStock(params: any) {
return request.get<any>({
url: '/admin/stock/order/print',
params
})
}
//审核详情
static stockapprovaldetailborder(params: any) {
return request.get<any>({
url: '/admin/approval/records/border',
params
})
}
//审批转交
static stockapprovaltransfer(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/approval/records/transfer',
params
})
}
//审批时间轴
static stockapprovaltimeline(params: any) {
return request.get<any>({
url: '/admin/approval/records/timeline',
params
})
}
}
+183
View File
@@ -0,0 +1,183 @@
import request from '@/utils/http'
export class commonService {
//通知列表
static getNoticeList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/common/notice/list',
params
})
}
//通知详情
static getNoticeDetail(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/common/notice/show',
params
})
}
//通知已读
static readNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/notice/read',
params
})
}
//通知删除
static delNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/notice/destroy',
params
})
}
//通知未读列表
static getUnReadNoticeList() {
return request.get<any>({
url: '/common/notice/unread'
})
}
//货币下拉列表
static getCurrencySelect() {
return request.get<any>({
url: '/common/preset/currency'
})
}
//国家下拉列表
static getCountrySelect() {
return request.get<any>({
url: '/crm/customer/getCountry'
})
}
//品牌下拉列表
static getBrandSelect() {
return request.get<any>({
url: '/common/preset/brand'
})
}
//车系下拉列表
static getSeriesSelect(params: any) {
return request.get<any>({
url: '/common/preset/brand/series',
params
})
}
//车型下拉列表
static getModelsSelect(params: any) {
return request.get<any>({
url: '/crm/vehicle/sku/preset',
params
})
}
//港口下拉列表
static getPortSelect(params: any) {
return request.get<any>({
url: '/common/preset/port',
params
})
}
//颜色下拉列表
static getColorSelect(params: any) {
return request.get<any>({
url: '/common/preset/color',
params
})
}
//改装下拉列表
static getModificationSelect() {
return request.get<any>({
url: '/common/preset/modification'
})
}
//字典下拉框
static getDictSelect(params: any) {
return request.get<any>({
url: '/admin/dict/scope',
params
})
}
//字典项下拉框
static getDictItemSelect(params: any) {
return request.get<any>({
url: '/common/preset/dictItem',
params
})
}
//字典分类下拉框
static getDictTypeSelect() {
return request.get<any>({
url: '/admin/dict/group'
})
}
//账户下拉框
static getAccountSelect() {
return request.get<any>({
url: '/common/preset/account'
})
}
//名下客户下拉框
static getCustomerSelect() {
return request.get<any>({
url: '/crm/customer/preset'
})
}
//角色下拉框
static getRoleSelect() {
return request.get<any>({
url: '/common/preset/roles'
})
}
//用户下拉框
static getUserSelect() {
return request.get<any>({
url: '/common/preset/users'
})
}
//用户下拉框
static getUserSelectparams(params: any) {
return request.get<any>({
url: '/common/preset/users',
params
})
}
//签名 编辑
static signatureEdit(params: any) {
return request.post<any>({
url: '/admin/user/update/sign',
params
})
}
//人员分组场景
static getUserGroupScene() {
return request.get<any>({
url: '/common/preset/usergroup/scene'
})
}
//人员分组
static getUserGroupSelect(params: any) {
return request.get<any>({
url: '/common/preset/usergroup',
params
})
}
//关键字抓取车辆
static getcrawlKeyword(params: any) {
return request.get<any>({
// url: '/common/brand/series/crawlKeyword',
url: '/common/brand/series/crawl',
params
})
}
//关键字抓取车辆保存
static crawlModelCreate(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/common/brand/series/crawlKeywordCreate',
params
})
}
//仪表盘选择下拉
static getDashboardSelect() {
return request.get<any>({
url: '/admin/resources/list/menu?is_home=1'
})
}
}
+323
View File
@@ -0,0 +1,323 @@
import request from '@/utils/http'
export class CrmService {
// 客户管理
//客户列表
static getcustomerList(params: Api.Common.PaginatingParams) {
return request.get<Api.Crm.CrmListData>({
url: '/crm/customer/list',
params
})
}
// 获取客户列表详情
static getcustomerdetail(params: any) {
return request.get<Api.Crm.CrmListData>({
url: '/crm/customer/detail',
params
})
}
//添加客户
static customerAdd(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/add',
params
// showErrorMessage: false // 不显示错误消息
})
}
//删除客户
static getcustomerdelete(params: any) {
return request.get<any>({
url: '/crm/customer/delete',
params
})
}
//标记无效
static customerReport(params: any) {
return request.get<any>({
url: '/crm/customer/report',
params
// showErrorMessage: false // 不显示错误消息
})
}
//切换等级crm/customer/grade
static customergrade(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/grade',
params
// showErrorMessage: false // 不显示错误消息
})
}
//渠道下拉
static getChannel() {
return request.get<any>({
url: '/crm/customer/getChannel'
})
}
//国家下拉
static getCountry() {
return request.get<any>({
url: '/crm/customer/getCountry'
})
}
//商机
static getMagnitude() {
return request.get<Api.Brand.BrandInfo>({
url: '/crm/customer/getMagnitude'
})
}
//等级
static getGrade() {
return request.get<Api.Brand.BrandInfo>({
url: '/crm/customer/getGrade'
})
}
//标签列表/crm/tagGroup/list
static tagGrouplist(params: any) {
return request.get<any>({
url: '/crm/tagGroup/list',
params
})
}
//更新客户标签 /crm/tagGroup/updatePivot
static tagGroupupdatePivot(params: any) {
return request.get<Api.Work.AddResponse>({
url: '/crm/tagGroup/updatePivot',
params
})
}
//跟进列表crm/follow/list
static getfollowlist(params: any) {
return request.get<any>({
url: '/crm/follow/list',
params
})
}
//添加跟进 /crm/follow/add
static followAdd(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/follow/add',
params
// showErrorMessage: false // 不显示错误消息
})
}
//待跟进 /crm/customer/waitFollow
static waitFollow(params: any) {
return request.get<any>({
url: '/crm/customer/waitFollow',
params
})
}
//设置下次跟进时间 followNext crm/customer/followNext
static followNext(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/followNext',
params
// showErrorMessage: false // 不显示错误消息
})
}
//crm 个人信息修改
static crmuseredit(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/user/edit',
params
// showErrorMessage: false // 不显示错误消息
})
}
//领取公海crm/customer/claim
static customerclaim(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/claim',
params
// showErrorMessage: false // 不显示错误消息
})
}
//客户回收 恢复 /crm/customer/restore
static customerrestore(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/restore',
params
// showErrorMessage: false // 不显示错误消息
})
}
//crm 获取用户信息 common/user/get
static userget() {
return request.get<any>({
url: '/common/user/get'
})
}
//修改密码 crm/user/savePassword
static savePassword(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/user/savePassword',
params
// showErrorMessage: false // 不显示错误消息
})
}
//首页统计
static homestatistics(params: any) {
return request.get<any>({
url: '/crm/customer/statistics',
params
})
}
//从属客户列表
static getCustomercsList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/crm/customer/index',
params
})
}
//从属客户转移
static cedeCustomer(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/cede',
params
// showErrorMessage: false // 不显示错误消息
})
}
//所属员工
static getCustomerUserList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/common/user/team',
params
})
}
//标记离职
static markLeave(params: any) {
return request.get<Api.Work.AddResponse>({
url: '/common/user/resign',
params
// showErrorMessage: false // 不显示错误消息
})
}
//客户删除
static crmCustomerDelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/crm/customer/delete',
params
// showErrorMessage: false // 不显示错误消息
})
}
//通知客户重复
static repeatCustomer(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/distinctNotice',
params
// showErrorMessage: false // 不显示错误消息
})
}
//备货单
static getStockList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/crm/stock/order/list',
params
})
}
//添加备货单
static addStock(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/stock/order/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//详情备货单
static getStockDetail(params: any) {
return request.get<any>({
url: '/crm/stock/order/show',
params
})
}
//编辑备货单
static editStock(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/stock/order/update',
params
})
}
//备货单查看
static checkStock(params: any) {
return request.get<any>({
url: '/crm/stock/order/detail',
params
})
}
//提交备货单
static submitStock(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/stock/order/approval',
params
})
}
//打印
static printStock(params: any) {
return request.get<any>({
url: '/crm/stock/order/print',
params
})
}
//审批记录
static stockapprovalrecord(params: any) {
return request.get<any>({
url: '/crm/approval/records/todo',
params
})
}
//审批通过/拒绝
static stockapprovalpass(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/approval/records/action',
params
})
}
//审批记录
static stockapprovallogs(params: any) {
return request.get<any>({
url: '/crm/approval/records/logs',
params
})
}
//终极备货单 提交
static stockultimateapproval(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/stock/order/ultimate/approval',
params
})
}
//备货单撤回
static stockrecall(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/stock/order/revoke/approval',
params
})
}
//备货单作废
static stockinvalid(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/stock/order/nullify/approval',
params
})
}
//备货单删除
static stockdelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/stock/order/destroy',
params
})
}
//审核详情
static stockapprovaldetailborder(params: any) {
return request.get<any>({
url: '/crm/approval/records/border',
params
})
}
//审批转交
static stockapprovaltransfer(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/approval/records/transfer',
params
})
}
}
+221
View File
@@ -0,0 +1,221 @@
import request from '@/utils/http'
export class vehicleService {
// 颜色管理
static getColorList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/crm/color/list',
params
})
}
static colorAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/color/store',
params
})
}
static colorDetail(params: any) {
return request.get<any>({
url: '/crm/color/show',
params
})
}
static colorEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/color/update',
params
})
}
static colorDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/color/destroy',
params
})
}
// 车辆列表
static getVehicleList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/crm/vehicle/sku/list',
params
})
}
static vehicleAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/vehicle/sku/store',
params
})
}
static vehicleDetail(params: any) {
return request.get<any>({
url: '/crm/vehicle/sku/show',
params
})
}
static vehicleEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/vehicle/sku/update',
params
})
}
static vehicleDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/vehicle/sku/destroy',
params
})
}
// 改装管理
static getModificationList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/crm/modification/list',
params
})
}
static modificationAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/modification/store',
params
})
}
static modificationDetail(params: any) {
return request.get<any>({
url: '/crm/modification/show',
params
})
}
static modificationEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/modification/update',
params
})
}
static modificationDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/crm/modification/destroy',
params
})
}
// 港口管理
static getPortList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/common/port/list',
params
})
}
static portAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/port/store',
params
})
}
static portDetail(params: any) {
return request.get<any>({
url: '/common/port/show',
params
})
}
static portEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/port/update',
params
})
}
static portDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/port/destroy',
params
})
}
// 车辆品牌列表
static getBrandList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/common/brand/list',
params
})
}
// 车辆品牌新增/详情/编辑/删除
static brandAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/store',
params
})
}
static brandDetail(params: any) {
return request.get<any>({
url: '/common/brand/show',
params
})
}
static brandEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/update',
params
})
}
static brandDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/destroy',
params
})
}
// 车辆系列列表
static getSeriesList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/common/brand/series/list',
params
})
}
// 车辆系列新增/详情/编辑/删除
static seriesAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/series/store',
params
})
}
static seriesDetail(params: any) {
return request.get<any>({
url: '/common/brand/series/show',
params
})
}
static seriesEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/series/update',
params
})
}
static seriesDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/series/destroy',
params
})
}
// 车系采集相关接口
static collectVehicleSeries(params: any) {
return request.get<any>({
url: '/common/brand/crawlSeries',
params
})
}
static saveVehicleSeries(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/crawlSeriesCreate',
params
})
}
// 车型采集相关接口
static collectVehicleModels(params: any) {
return request.get<any>({
url: '/common/brand/series/crawlModels',
params
})
}
static saveVehicleModels(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/series/crawlModelCreate',
params
})
}
}
+74
View File
@@ -0,0 +1,74 @@
import request from '@/utils/http'
export class financeService {
// 货币列表
static getCurrencyList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/currency/list',
params
})
}
// 货币添加
static currencyAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/currency/store',
params
})
}
// 货币详情
static currencyDetail(params: any) {
return request.get<any>({
url: '/admin/currency/show',
params
})
}
// 货币编辑
static currencyEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/currency/update',
params
})
}
// 货币删除
static currencyDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/currency/destroy',
params
})
}
// 收款账户列表
static getAccountList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/account/list',
params
})
}
// 收款账户添加
static accountAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/account/store',
params
})
}
// 收款账户详情
static accountDetail(params: any) {
return request.get<any>({
url: '/admin/account/show',
params
})
}
// 收款账户编辑
static accountEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/account/update',
params
})
}
// 收款账户删除
static accountDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/account/destroy',
params
})
}
}
+5 -7
View File
@@ -1,11 +1,10 @@
import { asyncRoutes } from '@/router/routes/asyncRoutes'
import { menuDataToRouter } from '@/router/utils/menuToRouter'
import { AppRouteRecord } from '@/types/router'
import { useUserStore } from '@/store/modules/user'
import { UserService } from '@/api/usersApi'
interface MenuResponse {
menuList: AppRouteRecord[]
}
// 菜单接口
export const menuService = {
async getMenuList(delay = 300): Promise<MenuResponse> {
@@ -15,28 +14,27 @@ export const menuService = {
if (!userStore.isLogin) {
throw new Error('用户未登录')
}
const { menu } = await UserService.getMenuList()
const menuData = menu
// const userStore = useUserStore()
// 模拟接口返回的菜单数据
const menuData = userStore.info.menu
// 验证菜单数据
if (!menuData || !Array.isArray(menuData) || menuData.length === 0) {
ElMessage.error('暂无系统菜单权限')
return { menuList: [] }
}
// console.log('menuData', menuData)
// 处理菜单数据
const menuList = menuData.map((route: any) => menuDataToRouter(route))
// 验证处理后的菜单列表
if (!menuList || menuList.length === 0) {
ElMessage.error('菜单数据处理失败')
return { menuList: [] }
}
// 模拟接口延迟
await new Promise((resolve) => setTimeout(resolve, delay))
return { menuList }
} catch (error) {
console.error('获取菜单失败:', error)
throw error instanceof Error ? error : new Error('获取菜单失败')
+348
View File
@@ -0,0 +1,348 @@
import request from '@/utils/http'
export class systemService {
//系统列表
static getSystemList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/systems/list',
params
})
}
//系统添加
static systemAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/systems/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//系统详情
static systemDetail(params: any) {
return request.get<any>({
url: '/admin/systems/show',
params
})
}
//系统编辑
static systemEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/systems/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//系统删除
static systemDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/systems/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//飞书应用列表
static getFeishuAppList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/corp/feishu/list',
params
})
}
//飞书应用添加
static feishuAppAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/feishu/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//飞书应用详情
static feishuAppDetail(params: any) {
return request.get<any>({
url: '/admin/corp/feishu/show',
params
})
}
//飞书应用编辑
static feishuAppEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/feishu/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//飞书应用删除
static feishuAppDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/feishu/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公司列表
static getCompanyList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/corp/list',
params
})
}
//公司添加
static companyAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公司详情
static companyDetail(params: any) {
return request.get<any>({
url: '/admin/corp/show',
params
})
}
//公司编辑
static companyEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公司删除
static companyDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色列表
static getRoleList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/role/list',
params
})
}
//角色添加
static roleAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色详情
static roleDetail(params: any) {
return request.get<any>({
url: '/admin/role/show',
params
})
}
//角色编辑
static roleEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色删除
static roleDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色权限设置
static roleAuth(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/menu/resource',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色下拉框
static getRoleSelectList() {
return request.get<Api.Common.CommonListData>({
url: '/common/preset/roles'
})
}
//菜单列表
static getMenutreeList(params: any) {
return request.get<any>({
url: '/admin/resources/list/tree',
params
})
}
static getMenuList() {
return request.get<Api.Common.CommonListData>({
url: '/admin/resources/list/menu'
})
}
//菜单添加
static menuAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/resources/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//菜单详情
static menuDetail(params: any) {
return request.get<any>({
url: '/admin/resources/show',
params
})
}
//菜单编辑
static menuEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/resources/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//菜单删除
static menuDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/resources/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//权限菜单
static getPermissionMenuList(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/resources/list/menu/tree',
params
})
}
//通知列表
static getNoticeList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/notice/list',
params
})
}
//通知详情
static getNoticeDetail(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/notice/show',
params
})
}
//通知删除
static delNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/notice/destroy',
params
})
}
//通知推送
static sendNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/notice/push',
params
})
}
// 字典类型列表
static getDictTypeList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/dict/list',
params
})
}
// 字典类型新增/详情/编辑/删除
static dictTypeAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dict/store',
params
})
}
static dictTypeDetail(params: any) {
return request.get<any>({
url: '/admin/dict/show',
params
})
}
static dictTypeEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dict/update',
params
})
}
static dictTypeDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dict/destroy',
params
})
}
// 字典项列表
static getDictItemList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/dict/item/list',
params
})
}
// 字典项新增/详情/编辑/删除
static dictItemAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dict/item/store',
params
})
}
static dictItemDetail(params: any) {
return request.get<any>({
url: '/admin/dict/item/show',
params
})
}
static dictItemEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dict/item/update',
params
})
}
static dictItemDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dict/item/destroy',
params
})
}
//仪表盘设置列表
static getDashboardList(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/dashboard/rule/list',
params
})
}
//仪表盘设置新增
static dashboardAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dashboard/rule/store',
params
})
}
//仪表盘设置详情
static dashboardDetail(params: any) {
return request.get<any>({
url: '/admin/dashboard/rule/show',
params
})
}
//仪表盘设置编辑
static dashboardEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dashboard/rule/update',
params
})
}
//仪表盘设置删除
static dashboardDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/dashboard/rule/destroy',
params
})
}
}
+36 -2
View File
@@ -7,6 +7,12 @@ export class UserService {
url: '/common/preset/corps'
})
}
//获取系统
static getsystemsList() {
return request.get<Api.Brand.BrandInfo>({
url: '/common/preset/systems'
})
}
//注册
static register(params: any) {
return request.post<Api.Auth.registerResponse>({
@@ -16,7 +22,7 @@ export class UserService {
})
}
//获取 飞书信息
static getfeishuAppList(params: { corp_id: string }) {
static getfeishuAppList(params: any) {
return request.get<Api.Brand.BrandInfo>({
url: '/common/preset/feishu/app',
params
@@ -37,7 +43,7 @@ export class UserService {
params
})
}
//飞书凭证
//飞书凭证
static getfeishuscope() {
return request.get<Api.Auth.Authfeishuscope>({
url: '/common/auth/feishu/scope'
@@ -66,4 +72,32 @@ export class UserService {
params
})
}
//获取菜单
static getMenuList() {
return request.get<Api.User.UserInfo>({
url: '/common/preset/menus'
})
}
//个人信息编辑
static editUserInfo(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/user/edit',
params
})
}
//离职分配
static leaveUser(params: any) {
return request.get<Api.Work.AddResponse>({
url: '/admin/crm/customer/allocation',
params
})
}
//客户删除
static crmCustomerDelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/crm/customer/delete',
params
// showErrorMessage: false // 不显示错误消息
})
}
}
+224
View File
@@ -0,0 +1,224 @@
import request from '@/utils/http'
export class vehicleService {
// 车辆品牌列表
static getBrandList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/brand/list',
params
})
}
// 车辆品牌新增/详情/编辑/删除
static brandAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/brand/store',
params
})
}
static brandDetail(params: any) {
return request.get<any>({
url: '/admin/brand/show',
params
})
}
static brandEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/brand/update',
params
})
}
static brandDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/brand/destroy',
params
})
}
// 车辆系列列表
static getSeriesList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/brand/series/list',
params
})
}
// 车辆系列新增/详情/编辑/删除
static seriesAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/brand/series/store',
params
})
}
static seriesDetail(params: any) {
return request.get<any>({
url: '/admin/brand/series/show',
params
})
}
static seriesEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/brand/series/update',
params
})
}
static seriesDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/brand/series/destroy',
params
})
}
// 颜色管理
static getColorList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/color/list',
params
})
}
static colorAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/color/store',
params
})
}
static colorDetail(params: any) {
return request.get<any>({
url: '/admin/color/show',
params
})
}
static colorEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/color/update',
params
})
}
static colorDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/color/destroy',
params
})
}
// 改装管理
static getModificationList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/modification/list',
params
})
}
static modificationAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/modification/store',
params
})
}
static modificationDetail(params: any) {
return request.get<any>({
url: '/admin/modification/show',
params
})
}
static modificationEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/modification/update',
params
})
}
static modificationDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/modification/destroy',
params
})
}
// 港口管理
static getPortList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/port/list',
params
})
}
static portAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/port/store',
params
})
}
static portDetail(params: any) {
return request.get<any>({
url: '/admin/port/show',
params
})
}
static portEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/port/update',
params
})
}
static portDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/port/destroy',
params
})
}
// 车辆列表
static getVehicleList(params: Api.Common.PaginatingParams & any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/vehicle/sku/list',
params
})
}
static vehicleAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/vehicle/sku/store',
params
})
}
static vehicleDetail(params: any) {
return request.get<any>({
url: '/admin/vehicle/sku/show',
params
})
}
static vehicleEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/vehicle/sku/update',
params
})
}
static vehicleDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/vehicle/sku/destroy',
params
})
}
// 车系采集相关接口
static collectVehicleSeries(params: any) {
return request.get<any>({
url: '/common/brand/crawlSeries',
params
})
}
static saveVehicleSeries(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/crawlSeriesCreate',
params
})
}
// 车型采集相关接口
static collectVehicleModels(params: any) {
return request.get<any>({
url: '/common/brand/series/crawlModels',
params
})
}
static saveVehicleModels(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/brand/series/crawlModelCreate',
params
})
}
}
+6 -5
View File
@@ -39,13 +39,14 @@ export class WorkService {
// showErrorMessage: false // 不显示错误消息
})
}
//工单状态下拉选择框
static getStatus() {
//工单状态下拉选择框
static getStatus(params: any) {
return request.get<Api.Brand.BrandInfo>({
url: '/ticket/ticket/getStatus'
url: '/ticket/ticket/getStatus',
params
})
}
//工单沟通记录
//工单沟通记录
static getconversationList(params: any) {
return request.get<Api.Work.WorkListData>({
url: '/ticket/conversation/list',
@@ -53,7 +54,7 @@ export class WorkService {
})
}
//添加工单记录 ticket/conversation/add
static conversationAdd(params:any) {
static conversationAdd(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/conversation/add',
params
+426
View File
@@ -0,0 +1,426 @@
{
"wordGeoCoordMap":{
"阿富汗": [67.709953, 33.93911],
"安哥拉": [17.873887, -11.202692],
"阿尔巴尼亚": [20.168331, 41.153332],
"阿拉伯联合酋长国": [53.847818, 23.424076],
"阿根廷": [-63.61667199999999, -38.416097],
"亚美尼亚": [45.038189, 40.069099],
"法属南半球和南极领地": [69.348557, -49.280366],
"澳大利亚": [133.775136, -25.274398],
"奥地利": [14.550072, 47.516231],
"阿塞拜疆": [47.576927, 40.143105],
"布隆迪": [29.918886, -3.373056],
"比利时": [4.469936, 50.503887],
"贝宁": [2.315834, 9.30769],
"布基纳法索": [-1.561593, 12.238333],
"孟加拉人民共和国": [90.356331, 23.684994],
"保加利亚": [25.48583, 42.733883],
"巴哈马": [-77.39627999999999, 25.03428],
"波斯尼亚和黑塞哥维那": [17.679076, 43.915886],
"白俄罗斯": [27.953389, 53.709807],
"伯利兹": [-88.49765, 17.189877],
"百慕大": [-64.7505, 32.3078],
"玻利维亚": [-63.58865299999999, -16.290154],
"巴西": [-51.92528, -14.235004],
"文莱": [114.727669, 4.535277],
"不丹": [90.433601, 27.514162],
"博茨瓦纳": [24.684866, -22.328474],
"中非共和国": [20.939444, 6.611110999999999],
"加拿大": [-106.346771, 56.130366],
"瑞士": [8.227511999999999, 46.818188],
"智利": [-71.542969, -35.675147],
"中华人民共和国": [104.195397, 35.86166],
"象牙海岸": [-5.547079999999999, 7.539988999999999],
"喀麦隆": [12.354722, 7.369721999999999],
"刚果民主共和国": [21.758664, -4.038333],
"刚果共和国": [15.827659, -0.228021],
"哥伦比亚": [-74.297333, 4.570868],
"哥斯达黎加共和国": [-83.753428, 9.748916999999999],
"古巴": [-77.781167, 21.521757],
"北塞浦路斯": [33.429859, 35.126413],
"塞浦路斯": [33.429859, 35.126413],
"捷克共和国": [15.472962, 49.81749199999999],
"德国": [10.451526, 51.165691],
"吉布提": [42.590275, 11.825138],
"丹麦": [9.501785, 56.26392],
"多明尼加共和国": [-70.162651, 18.735693],
"阿尔及利亚": [1.659626, 28.033886],
"厄瓜多尔": [-78.18340599999999, -1.831239],
"埃及": [30.802498, 26.820553],
"厄立特里亚": [39.782334, 15.179384],
"西班牙": [-3.74922, 40.46366700000001],
"爱沙尼亚": [25.013607, 58.595272],
"埃塞俄比亚": [40.489673, 9.145000000000001],
"芬兰": [25.748151, 61.92410999999999],
"斐": [178.065032, -17.713371],
"福克兰群岛": [-59.523613, -51.796253],
"法国": [2.213749, 46.227638],
"加蓬共和国": [11.609444, -0.803689],
"英国": [-3.435973, 55.378051],
"格鲁吉亚": [-82.9000751, 32.1656221],
"加纳": [-1.023194, 7.946527],
"几内亚": [-9.696645, 9.945587],
"冈比亚": [-15.310139, 13.443182],
"几内亚比绍": [-15.180413, 11.803749],
"赤道几内亚": [10.267895, 1.650801],
"希腊": [21.824312, 39.074208],
"格陵兰": [-42.604303, 71.706936],
"危地马拉": [-90.23075899999999, 15.783471],
"法属圭亚那": [-53.125782, 3.933889],
"圭亚那": [-58.93018, 4.860416],
"洪都拉斯": [-86.241905, 15.199999],
"克罗地亚": [15.2, 45.1],
"海地": [-72.285215, 18.971187],
"匈牙利共和国": [19.503304, 47.162494],
"印尼": [113.921327, -0.789275],
"爱尔兰": [-8.24389, 53.41291],
"伊朗": [53.688046, 32.427908],
"伊拉克": [43.679291, 33.223191],
"冰岛": [-19.020835, 64.963051],
"以色列": [34.851612, 31.046051],
"意大利": [12.56738, 41.87194],
"牙买加": [-77.297508, 18.109581],
"约旦": [36.238414, 30.585164],
"日本": [138.252924, 36.204824],
"哈萨克斯坦": [66.923684, 48.019573],
"肯尼亚": [37.906193, -0.023559],
"吉尔吉斯斯坦": [74.766098, 41.20438],
"柬埔寨": [104.990963, 12.565679],
"韩国": [127.766922, 35.907757],
"科索沃": [20.902977, 42.6026359],
"科威特": [47.481766, 29.31166],
"老挝": [102.495496, 19.85627],
"黎巴嫩": [35.862285, 33.854721],
"利比里亚共和国": [-9.429499000000002, 6.428055],
"利比亚": [17.228331, 26.3351],
"斯里兰卡": [80.77179699999999, 7.873053999999999],
"莱索托": [28.233608, -29.609988],
"立陶宛": [23.881275, 55.169438],
"巴林": [50.30, 26.10],
"卢森堡": [6.129582999999999, 49.815273],
"拉脱维亚共和国": [24.603189, 56.879635],
"摩洛哥": [-7.092619999999999, 31.791702],
"摩尔多瓦": [28.369885, 47.411631],
"马达加斯加": [46.869107, -18.766947],
"墨西哥": [-102.552784, 23.634501],
"马其顿": [21.745275, 41.608635],
"马里": [-3.996166, 17.570692],
"缅甸": [95.956223, 21.913965],
"黑山共和国": [19.37439, 42.708678],
"蒙古": [103.846656, 46.862496],
"莫桑比克": [35.529562, -18.665695],
"毛里塔尼亚": [-10.940835, 21.00789],
"马拉维": [34.301525, -13.254308],
"马来西亚": [101.975766, 4.210484],
"纳米比亚": [18.49041, -22.95764],
"新喀里多尼亚": [165.618042, -20.904305],
"尼日尔": [8.081666, 17.607789],
"尼日利亚": [8.675277, 9.081999],
"尼加拉瓜": [-85.207229, 12.865416],
"荷兰": [5.291265999999999, 52.132633],
"挪威": [8.468945999999999, 60.47202399999999],
"尼泊尔": [84.12400799999999, 28.394857],
"新西兰": [174.885971, -40.900557],
"阿曼": [55.923255, 21.512583],
"巴基斯坦": [69.34511599999999, 30.375321],
"巴拿马共和国": [-80.782127, 8.537981],
"秘鲁": [-75.015152, -9.189967],
"菲律宾": [121.774017, 12.879721],
"巴布亚新几内亚": [143.95555, -6.314992999999999],
"波兰": [19.145136, 51.919438],
"波多黎各": [-66.590149, 18.220833],
"北朝鲜": [127.510093, 40.339852],
"葡萄牙": [-8.224454, 39.39987199999999],
"巴拉圭": [-58.443832, -23.442503],
"卡塔尔": [51.183884, 25.354826],
"罗马尼亚": [24.96676, 45.943161],
"俄罗斯": [105.318756, 61.52401],
"俄罗斯联邦": [105.318756, 61.52401],
"卢旺达": [29.873888, -1.940278],
"西撒哈拉": [-12.885834, 24.215527],
"沙特阿拉伯": [45.079162, 23.885942],
"苏丹": [30.217636, 12.862807],
"南苏丹": [31.3069788, 6.876991899999999],
"塞内加尔": [-14.452362, 14.497401],
"所罗门群岛": [160.156194, -9.64571],
"塞拉利昂": [-11.779889, 8.460555],
"萨尔瓦多": [-88.89653, 13.794185],
"索马里兰": [46.8252838, 9.411743399999999],
"索马里": [46.199616, 5.152149],
"塞尔维亚": [21.005859, 44.016521],
"苏里南": [-56.027783, 3.919305],
"斯洛伐克共和国": [19.699024, 48.669026],
"斯洛文尼亚": [14.995463, 46.151241],
"瑞典": [18.643501, 60.12816100000001],
"斯威士兰": [31.465866, -26.522503],
"叙利亚": [38.996815, 34.80207499999999],
"乍得": [18.732207, 15.454166],
"多哥": [0.824782, 8.619543],
"泰国": [100.992541, 15.870032],
"塔吉克斯坦共和国": [71.276093, 38.861034],
"土库曼斯坦": [59.556278, 38.969719],
"东帝汶": [125.727539, -8.874217],
"特里尼达和多巴哥": [-61.222503, 10.691803],
"突尼斯": [9.537499, 33.886917],
"土耳其": [35.243322, 38.963745],
"坦桑尼亚": [34.888822, -6.369028],
"乌干达": [32.290275, 1.373333],
"乌克兰": [31.16558, 48.379433],
"乌拉圭": [-55.765835, -32.522779],
"美国": [-95.712891, 37.09024],
"乌兹别克斯坦": [64.585262, 41.377491],
"委内瑞拉": [-66.58973, 6.42375],
"越南": [108.277199, 14.058324],
"瓦努阿图": [166.959158, -15.376706],
"西岸": [35.3027226, 31.9465703],
"也门": [48.516388, 15.552727],
"南非": [22.937506, -30.559482],
"赞比亚": [27.849332, -13.133897],
"津巴布韦": [29.154857, -19.015438],
"科摩罗": [43.872219, -11.875001],
"印度尼西亚": [106.515414, -6.103040],
"印度": [78.96288, 20.593684],
"刚果": [15.12, -4.09],
"萨摩亚独立国": [-171.46, 13.5],
"朝鲜": [125.30, 39.09],
"新加坡": [103.87546, 1.30243],
"中非": [18.33, 4.21],
"巴勒斯坦": [35.25, 31.79],
"圣卢西亚": [-60.58, 14.02],
"安提瓜和巴布达": [-61.48, 17.20],
"巴巴多斯": [-59.30, 13.05],
"开曼群岛": [-81.24, 19.20],
"法罗群岛": [-6.56, 62.05],
"圣文森特和格林纳丁斯": [-61.10, 13.10],
"特立尼达和多巴哥": [-60.32, 11.18],
"台澎金马单独关税区": [121.21, 25.02],
"中国香港": [114.12, 22.26],
"中国澳门": [113.5, 22.2],
"格林纳达": [-61.4, 12.7],
"塞舌尔共和国": [55.28, -4.4],
"多米尼克": [61.23, 15.18],
"斐济": [178.30, -18.06],
"科特迪瓦": [-5.17, 6.49],
"欧盟": [4.47, 50.50]
},
"nameMap":{
"Afghanistan": "阿富汗",
"Singapore": "新加坡",
"Angola": "安哥拉",
"Albania": "阿尔巴尼亚",
"United Arab Emirates": "阿拉伯联合酋长国",
"Argentina": "阿根廷",
"Armenia": "亚美尼亚",
"French Southern and Antarctic Lands": "法属南半球和南极领地",
"Australia": "澳大利亚",
"Austria": "奥地利",
"Azerbaijan": "阿塞拜疆",
"Seychelles": "塞舌尔共和国",
"Burundi": "布隆迪",
"Belgium": "比利时",
"Benin": "贝宁",
"Burkina Faso": "布基纳法索",
"Côte d Ivoire": "科特迪瓦",
"Bangladesh": "孟加拉人民共和国",
"Bulgaria": "保加利亚",
"The Bahamas": "巴哈马",
"Bosnia and Herzegovina": "波斯尼亚和黑塞哥维那",
"Belarus": "白俄罗斯",
"Belize": "伯利兹",
"Bermuda": "百慕大",
"Bolivia": "玻利维亚",
"Brazil": "巴西",
"Brunei": "文莱",
"Bhutan": "不丹",
"Botswana": "博茨瓦纳",
"Central African Republic": "中非共和国",
"Canada": "加拿大",
"Switzerland": "瑞士",
"Chile": "智利",
"China": "中华人民共和国",
"Ivory Coast": "象牙海岸",
"Cameroon": "喀麦隆",
"Dem. Rep. Congo": "刚果民主共和国",
"Republic of the Congo": "刚果共和国",
"Colombia": "哥伦比亚",
"Costa Rica": "哥斯达黎加共和国",
"Cuba": "古巴",
"Northern Cyprus": "北塞浦路斯",
"Cyprus": "塞浦路斯",
"Czech Republic": "捷克共和国",
"Germany": "德国",
"Djibouti": "吉布提",
"Denmark": "丹麦",
"Dominican Republic": "多明尼加共和国",
"Algeria": "阿尔及利亚",
"Ecuador": "厄瓜多尔",
"Egypt": "埃及",
"Eritrea": "厄立特里亚",
"Spain": "西班牙",
"Estonia": "爱沙尼亚",
"Ethiopia": "埃塞俄比亚",
"Bahrain": "巴林",
"Finland": "芬兰",
"Fiji": "斐济",
"Falkland Islands": "福克兰群岛",
"France": "法国",
"Gabon": "加蓬共和国",
"United Kingdom": "英国",
"Georgia": "格鲁吉亚",
"Ghana": "加纳",
"Guinea": "几内亚",
"Gambia": "冈比亚",
"Guinea Bissau": "几内亚比绍",
"Greece": "希腊",
"Greenland": "格陵兰",
"Guatemala": "危地马拉",
"French Guiana": "法属圭亚那",
"Guyana": "圭亚那",
"Honduras": "洪都拉斯",
"Croatia": "克罗地亚",
"Haiti": "海地",
"Hungary": "匈牙利共和国",
"Indonesia": "印度尼西亚",
"India": "印度",
"Ireland": "爱尔兰",
"Iran": "伊朗",
"Iraq": "伊拉克",
"Iceland": "冰岛",
"Israel": "以色列",
"Italy": "意大利",
"Jamaica": "牙买加",
"Jordan": "约旦",
"Japan": "日本",
"Kazakhstan": "哈萨克斯坦",
"Kenya": "肯尼亚",
"Kyrgyzstan": "吉尔吉斯斯坦",
"Cambodia": "柬埔寨",
"Kosovo": "科索沃",
"Kuwait": "科威特",
"Laos": "老挝",
"Lebanon": "黎巴嫩",
"Liberia": "利比里亚共和国",
"Libya": "利比亚",
"Sri Lanka": "斯里兰卡",
"Lesotho": "莱索托",
"Lithuania": "立陶宛",
"Luxembourg": "卢森堡",
"Latvia": "拉脱维亚共和国",
"Morocco": "摩洛哥",
"Moldova": "摩尔多瓦",
"Madagascar": "马达加斯加",
"Mexico": "墨西哥",
"Macedonia": "马其顿",
"Mali": "马里",
"Myanmar": "缅甸",
"Montenegro": "黑山共和国",
"Mongolia": "蒙古",
"Mozambique": "莫桑比克",
"Mauritania": "毛里塔尼亚",
"Malawi": "马拉维",
"Malaysia": "马来西亚",
"Namibia": "纳米比亚",
"New Caledonia": "新喀里多尼亚",
"Niger": "尼日尔",
"Nigeria": "尼日利亚",
"Nicaragua": "尼加拉瓜",
"Netherlands": "荷兰",
"Norway": "挪威",
"Nepal": "尼泊尔",
"New Zealand": "新西兰",
"Oman": "阿曼",
"Pakistan": "巴基斯坦",
"Panama": "巴拿马共和国",
"Peru": "秘鲁",
"Philippines": "菲律宾",
"Papua New Guinea": "巴布亚新几内亚",
"Poland": "波兰",
"Puerto Rico": "波多黎各",
"North Korea": "北朝鲜",
"Portugal": "葡萄牙",
"Paraguay": "巴拉圭",
"Qatar": "卡塔尔",
"Romania": "罗马尼亚",
"Russia": "俄罗斯",
"Rwanda": "卢旺达",
"Western Sahara": "西撒哈拉",
"Saudi Arabia": "沙特阿拉伯",
"Sudan": "苏丹",
"S. Sudan": "南苏丹",
"Senegal": "塞内加尔",
"Solomon Islands": "所罗门群岛",
"Sierra Leone": "塞拉利昂",
"El Salvador": "萨尔瓦多",
"Somaliland": "索马里兰",
"Somalia": "索马里",
"Republic of Serbia": "塞尔维亚",
"Suriname": "苏里南",
"Slovakia": "斯洛伐克共和国",
"Slovenia": "斯洛文尼亚",
"Sweden": "瑞典",
"Swaziland": "斯威士兰",
"Syria": "叙利亚",
"Chad": "乍得",
"Togo": "多哥",
"Thailand": "泰国",
"Tajikistan": "塔吉克斯坦共和国",
"Turkmenistan": "土库曼斯坦",
"East Timor": "东帝汶",
"Trinidad and Tobago": "特立尼达和多巴哥",
"Tunisia": "突尼斯",
"Turkey": "土耳其",
"Uganda": "乌干达",
"Ukraine": "乌克兰",
"Uruguay": "乌拉圭",
"United States": "美国",
"Uzbekistan": "乌兹别克斯坦",
"Venezuela": "委内瑞拉",
"Vietnam": "越南",
"Vanuatu": "瓦努阿图",
"West Bank": "西岸",
"Yemen": "也门",
"South Africa": "南非",
"Zambia": "赞比亚",
"Korea": "韩国",
"Tanzania": "坦桑尼亚",
"Zimbabwe": "津巴布韦",
"Congo": "刚果",
"Central African Rep.": "中非",
"Serbia": "塞尔维亚",
"Bosnia and Herz.": "波黑",
"Czech Rep.": "捷克",
"W. Sahara": "西撒哈拉",
"Lao PDR": "老挝",
"Dem. Rep. Korea": "朝鲜",
"Falkland Is.": "福克兰群岛",
"Timor-Leste": "东帝汶",
"Solomon Is.": "所罗门群岛",
"Palestine": "巴勒斯坦",
"N. Cyprus": "北塞浦路斯",
"Aland": "奥兰群岛",
"Fr. S. Antarctic Lands": "法属南半球和南极陆地",
"Mauritius": "毛里求斯",
"Comoros": "科摩罗",
"Eq. Guinea": "赤道几内亚",
"Guinea-Bissau": "几内亚比绍",
"Dominican Rep.": "多米尼加共和国",
"Saint Lucia": "圣卢西亚",
"Dominica": "多米尼克",
"Antigua and Barb.": "安提瓜和巴布达",
"U.S. Virgin Is.": "美国原始岛屿",
"Montserrat": "蒙塞拉特",
"Grenada": "格林纳达",
"Barbados": "巴巴多斯",
"Samoa": "萨摩亚独立国",
"Bahamas": "巴哈马",
"Cayman Is.": "开曼群岛",
"Faeroe Is.": "法罗群岛",
"IsIe of Man": "马恩岛",
"Malta": "马耳他共和国",
"Jersey": "泽西",
"Cape Verde": "佛得角共和国",
"Turks and Caicos Is.": "特克斯和凯科斯群岛",
"St. Vin. and Gren.": "圣文森特和格林纳丁斯"
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -6,7 +6,7 @@
//
'white': #ffffff,
'black': #000000,
'success': ('base': #13deb9),
'success': ('base': #00A43F),
'warning': ('base': #ffae1f),
'danger': ('base': #ff4d4f),
'error': ('base': #fa896b)
+1 -1
View File
@@ -8,7 +8,7 @@
//
'white': #ffffff,
'black': #000000,
'success': ('base': #13deb9),
'success': ('base': #00A43F),
'warning': ('base': #ffae1f),
'danger': ('base': #ff4d4f),
'error': ('base': #fa896b)
+9
View File
@@ -400,3 +400,12 @@
position: relative;
z-index: 10;
}
// 修复 el-table 空数据块样式
.el-table__empty-block {
height: 100% !important;
min-height: 200px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
@@ -0,0 +1,284 @@
<template>
<div class="multi-file-upload">
<!-- 上传按钮 -->
<el-upload
class="upload-box"
:action="actionImageUplode"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUpload"
:before-upload="beforeUpload"
multiple
:accept="accept"
>
<div class="upload-trigger">
<img class="upload-icon" :src="iconSrc" />
<div class="upload-text">
<span>{{ uploadText }}</span>
<span v-if="showHint" class="upload-hint">{{ hintText }}</span>
</div>
</div>
</el-upload>
<!-- 文件列表显示 -->
<div v-if="fileList.length > 0" class="files-list">
<div v-for="(file, index) in fileList" :key="index" class="file-item">
<div class="file-info">
<img v-if="isImageFile(file)" :src="file.url" class="file-preview-image" />
<div v-else class="file-icon">
<i class="el-icon-document"></i>
</div>
<span class="file-name">{{ file.name }}</span>
</div>
<div class="file-actions">
<el-button type="text" size="small" @click="filePreview(file)" icon="el-icon-download">
预览
</el-button>
<el-button type="text" size="small" @click="downloadFile(file)" icon="el-icon-download">
下载
</el-button>
<el-button
type="text"
size="small"
@click="removeFile(index)"
icon="el-icon-delete"
style="color: #f56c6c"
>
删除
</el-button>
</div>
</div>
</div>
<files-previews v-model="fileshow" :file="filepviews" @close="onClose" />
</div>
</template>
<script setup lang="ts" name="MultiFileUpload">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useUploadHeaders } from '@/composables/useUploadHeaders'
import FilesPreviews from '@/components/custom/files_previews.vue'
const actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=stock_orders`
const { uploadHeaders } = useUploadHeaders()
const fileshow = ref(false)
const filepviews = ref({} as FileItem)
function onClose() {
console.log('文件预览已关闭')
}
interface FileItem {
name: string
url: string
file_name: string
}
interface Props {
modelValue: FileItem[]
uploadText?: string
hintText?: string
showHint?: boolean
iconSrc?: string
accept?: string
maxSize?: number // MB
}
interface Emits {
(e: 'update:modelValue', value: FileItem[]): void
(e: 'change', value: FileItem[]): void
}
const props = withDefaults(defineProps<Props>(), {
uploadText: '点击上传文件',
hintText: '支持多个文件上传',
showHint: true,
iconSrc: '/src/assets/img/crm/contract.png',
accept: '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt',
maxSize: 10
})
const emit = defineEmits<Emits>()
const fileList = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
emit('change', value)
}
})
const filePreview = (file: FileItem) => {
filepviews.value = file
fileshow.value = true
}
const isImageFile = (file: FileItem) => {
const stripQuery = (s?: string) => (s ? s.split('?')[0] : '')
const byExt = (s?: string) => /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(stripQuery(s || ''))
// Prefer extension checks on url, then file_name, then name
if (byExt(file.url)) return true
if (byExt(file.file_name)) return true
if (byExt(file.name)) return true
// Fallback: some backends may return mime in file_name like 'image/jpeg'
return (file.file_name || '').toLowerCase().startsWith('image/')
}
const beforeUpload = (file: File) => {
const isValidType = new RegExp(props.accept.replace(/\./g, '\\.').replace(/,/g, '|'), 'i').test(
file.name
)
if (!isValidType) {
ElMessage.error(`只能上传${props.accept}格式的文件!`)
return false
}
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
return false
}
return true
}
const handleUpload = (response: any) => {
if (response.code === 200) {
const fileInfo: FileItem = {
name: response.data.original_name || '文件',
url: response.data.file_url,
file_name: response.data.file_name
}
const newFileList = [...fileList.value, fileInfo]
fileList.value = newFileList
ElMessage.success('文件上传成功!')
} else {
ElMessage.error(response.message || '文件上传失败!')
}
}
const removeFile = (index: number) => {
const newFileList = [...fileList.value]
newFileList.splice(index, 1)
fileList.value = newFileList
ElMessage.success('文件已删除')
}
const downloadFile = (file: FileItem) => {
// 创建下载链接
const link = document.createElement('a')
link.href = file.url
link.download = file.name
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 导出组件
defineExpose({})
</script>
<style scoped lang="scss">
.multi-file-upload {
.upload-box {
width: 100%;
}
.upload-trigger {
width: 100%;
height: 120px;
// border: 2px dashed #d9d9d9;
// border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
// background: #fafafa;
&:hover {
border-color: #409eff;
background: #f0f9ff;
}
}
.upload-icon {
width: 48px;
height: 48px;
margin-bottom: 8px;
}
.upload-text {
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
color: #606266;
}
.upload-hint {
font-size: 12px;
color: #999;
margin-top: 4px;
}
// 文件列表样式
.files-list {
margin: 16px 0;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #e4e7ed;
border-radius: 6px;
margin-bottom: 8px;
background: #fafafa;
transition: all 0.3s;
&:hover {
background: #f0f9ff;
border-color: #409eff;
}
}
.file-info {
display: flex;
align-items: center;
flex: 1;
}
.file-preview-image {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
margin-right: 12px;
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: #f5f7fa;
border-radius: 4px;
margin-right: 12px;
border: 1px dashed #d9d9d9;
i {
font-size: 20px;
color: #409eff;
}
}
.file-name {
font-size: 14px;
color: #606266;
word-break: break-all;
}
.file-actions {
display: flex;
gap: 8px;
}
}
</style>
@@ -54,23 +54,33 @@
</div>
</div>
</div> -->
<!-- 通知 -->
<div class="btn-box notice-btn" @click="visibleNotice">
<div class="btn notice-button">
<i
class="iconfont-sys notice-btn"
:class="{ 'bell-shake': isBellShaking }"
:style="isBellShaking ? 'color: #5d87ff' : ''"
>&#xe6c2;</i
>
<span class="count notice-btn" v-if="isBellShaking"></span>
</div>
</div>
<div class="btn-box screen-box" @click="clearCachefn">
<div class="btn" title="清除缓存">
<i class="iconfont-sys">&#xe847;</i>
</div>
</div>
<!-- 全屏按钮 -->
<div class="btn-box screen-box" @click="toggleFullScreen">
<div
:title="isFullscreen ? '退出全屏' : '全屏'"
class="btn"
:class="{ 'full-screen-btn': !isFullscreen, 'exit-full-screen-btn': isFullscreen }"
>
<i class="iconfont-sys">{{ isFullscreen ? '&#xe62d;' : '&#xe8ce;' }}</i>
</div>
</div>
<!-- 通知 -->
<!-- <div class="btn-box notice-btn" @click="visibleNotice">
<div class="btn notice-button">
<i class="iconfont-sys notice-btn">&#xe6c2;</i>
<span class="count notice-btn"></span>
</div>
</div> -->
<!-- 聊天 -->
<!-- <div class="btn-box chat-btn" @click="openChat">
<div class="btn chat-button">
@@ -79,7 +89,7 @@
</div>
</div> -->
<!-- 语言 -->
<div class="btn-box" v-if="showLanguage">
<!-- <div class="btn-box" v-if="showLanguage">
<el-dropdown @command="changeLanguage" popper-class="langDropDownStyle">
<div class="btn language-btn">
<i class="iconfont-sys">&#xe611;</i>
@@ -98,7 +108,7 @@
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div> -->
<!-- 设置 -->
<!-- <div class="btn-box" @click="openSetting">
<el-popover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
@@ -119,7 +129,7 @@
</div> -->
<!-- 切换主题 -->
<div class="btn-box" @click="themeAnimation">
<div class="btn theme-btn">
<div class="btn theme-btn" title="切换主题">
<i class="iconfont-sys">{{ isDark ? '&#xe6b5;' : '&#xe725;' }}</i>
</div>
</div>
@@ -138,7 +148,10 @@
popper-style="border: 1px solid var(--art-border-dashed-color); border-radius: calc(var(--custom-radius) / 2 + 4px); padding: 5px 16px; 5px 16px;"
>
<template #reference>
<img class="cover" src="@imgs/user/avatar.webp" />
<img
class="cover"
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
/>
</template>
<template #default>
<div class="user-menu-box">
@@ -155,7 +168,19 @@
<span class="email">{{ userInfo.user.email }}</span>
</div>
</div> -->
<ul class="user-menu">
<ul class="user-menu" style="border: none">
<li @click="gouserInfo()">
<i class="menu-icon iconfont-sys">&#xe734;</i>
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
</li>
<li @click="goPage('/work/myOrder')">
<i class="menu-icon iconfont-sys">&#xe828;</i>
<span class="menu-txt">我的工单</span>
</li>
<!-- <li @click="goPage('/crmpc/recycle')">
<i class="menu-icon iconfont-sys">&#xe828;</i>
<span class="menu-txt">回收站</span>
</li> -->
<!-- <li @click="goPage('/system/user-center')">
<i class="menu-icon iconfont-sys">&#xe734;</i>
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
@@ -168,10 +193,10 @@
<i class="menu-icon iconfont-sys">&#xe8d6;</i>
<span class="menu-txt">{{ $t('topBar.user.github') }}</span>
</li> -->
<li @click="lockScreen()">
<!-- <li @click="lockScreen()">
<i class="menu-icon iconfont-sys">&#xe817;</i>
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
</li>
</li> -->
<div class="line"></div>
<div class="logout-btn" @click="loginOut">
{{ $t('topBar.user.logout') }}
@@ -200,14 +225,14 @@
import { mittBus } from '@/utils/sys'
import { useMenuStore } from '@/store/modules/menu'
import AppConfig from '@/config'
import { manageService } from '@/api/adminApi'
import { languageOptions } from '@/locales'
const isWindows = navigator.userAgent.includes('Windows')
const { locale } = useI18n()
const settingStore = useSettingStore()
const userStore = useUserStore()
const router = useRouter()
const { getUserInfo, home_page } = useUserStore()
const {
showMenuButton,
showRefreshButton,
@@ -221,13 +246,17 @@
tabStyle
} = storeToRefs(settingStore)
const { language, getUserInfo: userInfo } = storeToRefs(userStore)
const { language } = storeToRefs(userStore)
const { menuList } = storeToRefs(useMenuStore())
const showNotice = ref(false)
const notice = ref(null)
const userMenuPopover = ref()
// 控制铃铛是否晃动
const isBellShaking = ref(false)
// 轮询定时器句柄,防止重复创建
let notificationPollTimer: number | undefined
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
@@ -249,10 +278,20 @@
onMounted(() => {
initLanguage()
document.addEventListener('click', bodyCloseNotice)
// 监听通知数据变化,控制铃铛晃动
watchNotificationData()
})
onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice)
// 清理事件监听器
mittBus.off('notification-data-changed')
mittBus.off('ws:message')
// 清理通知轮询
if (notificationPollTimer) {
clearInterval(notificationPollTimer)
notificationPollTimer = undefined
}
})
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
@@ -260,7 +299,14 @@
const toggleFullScreen = () => {
toggleFullscreen()
}
const clearCachefn = () => {
manageService.clearCache().then((res) => {
ElMessage.success(res.data?.message || '成功!')
// setTimeout(() => {
window.location.reload()
// }, 5000)
})
}
const topBarWidth = (): string => {
const { TOP, DUAL_MENU, TOP_LEFT } = MenuTypeEnum
const { getMenuOpenWidth } = settingStore
@@ -288,17 +334,20 @@
const goPage = (path: string) => {
router.push(path)
}
const toDocs = () => {
window.open(WEB_LINKS.DOCS)
const gouserInfo = () => {
router.push('/admin/user/user_info')
}
const toGithub = () => {
window.open(WEB_LINKS.GITHUB)
}
// const toDocs = () => {
// window.open(WEB_LINKS.DOCS)
// }
// const toGithub = () => {
// window.open(WEB_LINKS.GITHUB)
// }
const toHome = () => {
router.push(HOME_PAGE)
router.push(home_page)
}
const loginOut = () => {
@@ -338,8 +387,6 @@
if (showSettingGuide.value) {
settingStore.hideSettingGuide()
}
// 打开设置引导
// settingStore.openSettingGuide()
}
const openSearchDialog = () => {
@@ -377,9 +424,72 @@
userMenuPopover.value.hide()
}, 100)
}
// 监听通知数据变化,控制铃铛晃动
const watchNotificationData = () => {
// 定期检查通知数据
// const checkNotificationData = async () => {
// try {
// const { commonService } = await import('@/api/commonApi')
// const res = await commonService.getUnReadNoticeList()
// const noticeCount = res?.list?.length || 0
// isBellShaking.value = noticeCount > 0
// } catch (error) {
// console.error('获取通知数据失败:', error)
// isBellShaking.value = false
// }
// }
// // 立即检查一次
// checkNotificationData()
// // 每30秒检查一次通知数据,先清理旧定时器,避免重复
// if (notificationPollTimer) {
// clearInterval(notificationPollTimer)
// notificationPollTimer = undefined
// }
// notificationPollTimer = window.setInterval(checkNotificationData, 30000)
// 监听通知数据变化事件
mittBus.on('notification-data-changed', (count: unknown) => {
isBellShaking.value = typeof count === 'number' ? count > 0 : false
})
// 监听WebSocket消息,处理实时通知
mittBus.on('ws:message', (data: any) => {
if (
data &&
data.event &&
(data.event.includes('notice') ||
data.event.includes('notification') ||
data.event.includes('message'))
) {
isBellShaking.value = true
}
})
}
</script>
<style lang="scss" scoped>
@use './style';
@use './mobile';
// 铃铛晃动动画
.bell-shake {
animation: bellShake 1.2s ease-in-out infinite;
}
@keyframes bellShake {
0%,
100% {
transform: rotate(0deg);
}
10%,
30%,
50%,
70%,
90% {
transform: rotate(-10deg);
}
20%,
40%,
60%,
80% {
transform: rotate(10deg);
}
}
</style>
@@ -32,7 +32,10 @@
v-html="item.meta.icon"
></i>
<span>{{ formatMenuTitle(item.meta.title) }}</span>
<div class="badge" v-if="item.meta.showBadge"></div>
<div v-if="isMenuBadgeVisible(item.path) && !getMenuTextBadge(item.path)" class="badge"></div>
<div v-if="getMenuTextBadge(item.path)" class="text-badge">
{{ getMenuTextBadge(item.path) }}
</div>
</el-menu-item>
</template>
@@ -41,6 +44,7 @@
import { AppRouteRecord } from '@/types/router'
import { handleMenuJump } from '@/utils/navigation'
import { formatMenuTitle } from '@/router/utils/utils'
import { useMenuBadge } from '@/composables/useMenuBadge'
const props = defineProps({
item: {
@@ -60,6 +64,9 @@
const emit = defineEmits(['close'])
// 使用菜单徽标系统
const { isMenuBadgeVisible, getMenuTextBadge } = useMenuBadge()
// 计算当前项是否有子菜单
const hasChildren = computed(() => {
return props.item.children && props.item.children.length > 0
@@ -112,11 +112,11 @@
import { isIframe } from '@/utils/navigation'
import { handleMenuJump } from '@/utils/navigation'
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
import { useUserStore } from '@/store/modules/user'
const route = useRoute()
const router = useRouter()
const settingStore = useSettingStore()
const { home_page } = useUserStore()
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
storeToRefs(settingStore)
@@ -210,7 +210,7 @@
)
const toHome = () => {
router.push(HOME_PAGE)
router.push(home_page)
}
let screenWidth = 0
@@ -112,16 +112,17 @@
top: 0;
right: 12px;
bottom: 0;
min-width: 20px;
height: 20px;
padding: 0 5px;
min-width: 16px;
height: 16px;
// padding: 0 5px;
margin: auto;
font-size: 12px;
line-height: 20px;
font-size: 10px;
line-height: 16px;
color: #fff;
text-align: center;
background: #fd4e4e;
border-radius: 5px;
// border-radius: 5px;
border-radius: 50%;
}
.header {
@@ -5,7 +5,10 @@
<template #title>
<MenuItemIcon :icon="item.meta.icon" :color="theme?.iconColor" />
<span class="menu-name">{{ formatMenuTitle(item.meta.title) }}</span>
<div v-if="item.meta.showBadge" class="badge" style="right: 35px" />
<div v-if="isMenuBadgeVisible(item.path) && !getMenuTextBadge(item.path)" class="badge" style="right: 35px" />
<div v-if="getMenuTextBadge(item.path)" class="text-badge" style="right: 35px">
{{ getMenuTextBadge(item.path) }}
</div>
</template>
<SidebarSubmenu
:list="item.children"
@@ -26,9 +29,9 @@
<MenuItemIcon :icon="item.meta.icon" :color="theme?.iconColor" />
<template #title>
<span class="menu-name">{{ formatMenuTitle(item.meta.title) }}</span>
<div v-if="item.meta.showBadge" class="badge" />
<div v-if="item.meta.showTextBadge" class="text-badge">
{{ item.meta.showTextBadge }}
<div v-if="isMenuBadgeVisible(item.path) && !getMenuTextBadge(item.path)" class="badge" />
<div v-if="getMenuTextBadge(item.path)" class="text-badge">
{{ getMenuTextBadge(item.path) }}
</div>
</template>
</el-menu-item>
@@ -40,6 +43,7 @@
import type { AppRouteRecord } from '@/types/router'
import { formatMenuTitle } from '@/router/utils/utils'
import { handleMenuJump } from '@/utils/navigation'
import { useMenuBadge } from '@/composables/useMenuBadge'
// 类型定义
interface Props {
@@ -66,6 +70,9 @@
(e: 'close'): void
}>()
// 使用菜单徽标系统
const { isMenuBadgeVisible, getMenuTextBadge } = useMenuBadge()
// 计算属性
const filteredMenuItems = computed(() => filterRoutes(props.list))
@@ -9,43 +9,46 @@
@click.stop=""
>
<div class="header">
<span class="text">{{ $t('notice.title') }}</span>
<span class="btn">{{ $t('notice.btnRead') }}</span>
<!-- <span class="text">{{ $t('notice.title') }}</span> -->
<span class="text">消息</span>
<span class="btn" @click="readAll">{{ $t('notice.btnRead') }}</span>
</div>
<ul class="bar">
<li
v-for="(item, index) in barList"
:key="index"
:class="{ active: barActiveIndex === index }"
@click="changeBar(index)"
>
{{ item.name }} ({{ item.num }})
{{ item.name }} ({{ noticeList.length }})
</li>
</ul>
<div class="content">
<div class="scroll">
<!-- 通知 -->
<ul class="notice-list" v-show="barActiveIndex === 0">
<li v-for="(item, index) in noticeList" :key="index">
<ul class="notice-list">
<!-- <ul class="notice-list" v-show="barActiveIndex === 0"> -->
<li v-for="(item, index) in noticeList" :key="index" @click="showfn(item)" v-ripple>
<div
class="icon"
:style="{ background: getNoticeStyle(item.type).backgroundColor + '!important' }"
:style="{
background: getNoticeStyle(item.message.scene).backgroundColor + '!important'
}"
>
<i
class="iconfont-sys"
:style="{ color: getNoticeStyle(item.type).iconColor + '!important' }"
v-html="getNoticeStyle(item.type).icon"
:style="{ color: getNoticeStyle(item.message.scene).iconColor + '!important' }"
v-html="getNoticeStyle(item.message.scene).icon"
>
</i>
</div>
<div class="text">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
<h4>{{ item.message.title }}</h4>
<p>{{ item.message.created_at }}</p>
</div>
</li>
</ul>
<!-- 消息 -->
<ul class="user-list" v-show="barActiveIndex === 1">
<!-- <ul class="user-list" v-show="barActiveIndex === 1">
<li v-for="(item, index) in msgList" :key="index">
<div class="avatar">
<img :src="item.avatar" />
@@ -55,27 +58,27 @@
<p>{{ item.time }}</p>
</div>
</li>
</ul>
</ul> -->
<!-- 待办 -->
<ul class="base" v-show="barActiveIndex === 3">
<!-- <ul class="base" v-show="barActiveIndex === 3">
<li v-for="(item, index) in pendingList" :key="index">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</li>
</ul>
</ul> -->
<div class="empty-tips" v-show="barActiveIndex === 0 && noticeList.length === 0">
<div class="empty-tips" v-show="noticeList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
<!-- <div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
<div class="empty-tips" v-show="barActiveIndex === 2 && pendingList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
</div> -->
</div>
<div class="btn-wrapper">
<el-button class="view-all" @click="handleViewAll" v-ripple>
@@ -86,6 +89,29 @@
<div style="height: 100px"></div>
</div>
<!-- 全局通知详情弹窗 -->
<Teleport to="body">
<ElDialog v-model="dialogVisible" title="通知详情" align-center :append-to-body="true" width="640px">
<div class="detail-dialog">
<div class="section">
<div class="info-grid">
<div class="label">发送人</div>
<div class="value">{{ noticeDetail.sender_name || '-' }}</div>
</div>
<div class="info-grid">
<div class="label">通知标题</div>
<div class="value">{{ noticeDetail.title || '-' }}</div>
</div>
</div>
<div class="section">
<div class="label" style="margin-bottom: 8px">通知内容</div>
<div class="content-box">
<div class="message-text" v-html="noticeDetail.content"></div>
</div>
</div>
</div>
</ElDialog>
</Teleport>
</template>
<script setup lang="ts">
@@ -97,7 +123,10 @@
import avatar6 from '@/assets/img/avatar/avatar6.webp'
import AppConfig from '@/config'
import { useI18n } from 'vue-i18n'
import { commonService } from '@/api/commonApi'
import { mittBus } from '@/utils/sys'
const dialogVisible = ref(false)
const router = useRouter()
const { t } = useI18n()
const props = defineProps({
@@ -123,85 +152,38 @@
{
name: computed(() => t('notice.bar[0]')),
num: 1
},
{
name: computed(() => t('notice.bar[1]')),
num: 1
},
{
name: computed(() => t('notice.bar[2]')),
num: 0
}
])
const noticeList = [
{
title: '新增国际化',
time: '2024-6-13 0:10',
type: 'notice'
},
{
title: '冷月呆呆给你发了一条消息',
time: '2024-4-21 8:05',
type: 'message'
},
{
title: '小肥猪关注了你',
time: '2020-3-17 21:12',
type: 'collection'
},
{
title: '新增使用文档',
time: '2024-02-14 0:20',
type: 'notice'
},
{
title: '小肥猪给你发了一封邮件',
time: '2024-1-20 0:15',
type: 'email'
},
{
title: '菜单mock本地真实数据',
time: '2024-1-17 22:06',
type: 'notice'
}
]
const msgList: any = [
{
title: '池不胖 关注了你',
time: '2021-2-26 23:50',
avatar: avatar1
},
{
title: '唐不苦 关注了你',
time: '2021-2-21 8:05',
avatar: avatar2
},
{
title: '中小鱼 关注了你',
time: '2020-1-17 21:12',
avatar: avatar3
},
{
title: '何小荷 关注了你',
time: '2021-01-14 0:20',
avatar: avatar4
},
{
title: '誶誶淰 关注了你',
time: '2020-12-20 0:15',
avatar: avatar5
},
{
title: '冷月呆呆 关注了你',
time: '2020-12-17 22:06',
avatar: avatar6
}
]
const changeBar = (index: number) => {
barActiveIndex.value = index
const noticeList = ref<any>([])
const getNoticeList = async () => {
await commonService.getUnReadNoticeList().then((res: any) => {
noticeList.value = res.list
// 通知头部组件更新铃铛状态
mittBus.emit('notification-data-changed', res.list?.length || 0)
})
}
onMounted(() => {
getNoticeList()
// 监听WebSocket消息:收到新通知实时插入到列表首位
mittBus.on('ws:message', onWsMessage)
})
onUnmounted(() => {
mittBus.off('ws:message')
})
const onWsMessage = (data: any) => {
if (data && data.event === 'notify.receive') {
// 将新通知插入到列表首位。后端数据结构需包含 type 与 message 字段
// 若结构不同,可在此处做一次字段映射
console.log(data)
noticeList.value.unshift(data.data.notice_data)
console.log(noticeList.value)
mittBus.emit('notification-data-changed', noticeList.value.length)
}
}
// const changeBar = (index: number) => {
// barActiveIndex.value = index
// }
const getRandomColor = () => {
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length)
@@ -209,27 +191,27 @@
}
const noticeStyleMap = {
email: {
4: {
icon: '&#xe72e;',
iconColor: 'rgb(var(--art-warning))',
backgroundColor: 'rgb(var(--art-bg-warning))'
},
message: {
2: {
icon: '&#xe747;',
iconColor: 'rgb(var(--art-success))',
backgroundColor: 'rgb(var(--art-bg-success))'
},
collection: {
5: {
icon: '&#xe714;',
iconColor: 'rgb(var(--art-danger))',
backgroundColor: 'rgb(var(--art-bg-danger))'
},
user: {
3: {
icon: '&#xe608;',
iconColor: 'rgb(var(--art-info))',
backgroundColor: 'rgb(var(--art-bg-info))'
},
notice: {
1: {
icon: '&#xe6c2;',
iconColor: 'rgb(var(--art-primary))',
backgroundColor: 'rgb(var(--art-bg-primary))'
@@ -243,7 +225,11 @@
backgroundColor: getRandomColor()
}
const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
// 先转成 unknown,再转成 keyof 类型,避免 TS 报错
const key = type as unknown as keyof typeof noticeStyleMap
const style = noticeStyleMap[key] || defaultStyle
// const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
return {
...style,
@@ -264,27 +250,78 @@
}, 350)
}
}
const readAll = async () => {
const { message } = await commonService.readNotice({
msg_ids: 'all'
})
ElMessage.success(message)
getNoticeList()
}
// 查看全部
const handleViewAll = () => {
switch (barActiveIndex.value) {
case 0:
handleNoticeAll()
break
case 1:
handleMsgAll()
break
case 2:
handlePendingAll()
break
}
handleNoticeAll()
}
const noticeDetail = ref<any>({})
const showfn = async (row: any) => {
dialogVisible.value = true
await commonService.getNoticeDetail({ id: row.id }).then((res: any) => {
noticeDetail.value = res.message
showNotice(false) // 关闭通知组件
getNoticeList()
})
}
const handleNoticeAll = () => {
router.push('/common/notice')
showNotice(false) // 关闭通知组件
}
const handleNoticeAll = () => {}
const handleMsgAll = () => {}
const handlePendingAll = () => {}
</script>
<style lang="scss" scoped>
@use './style';
.el-button {
--el-button-border-color: #dcdfe6 !important;
--el-button-text-color: var(--el-text-color-regular);
}
.message-text {
padding: 10px 14px;
line-height: 1.7;
white-space: pre-line;
word-break: break-word;
}
.detail-dialog {
display: flex;
flex-direction: column;
gap: 16px;
}
.section {
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 12px 14px;
}
.info-grid {
display: grid;
grid-template-columns: 96px 1fr;
gap: 6px 12px;
align-items: center;
padding: 6px 0;
}
.label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.value {
color: var(--el-text-color-primary);
font-size: 14px;
}
.content-box {
background: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 8px;
padding: 10px 12px;
max-height: 320px;
overflow: auto;
}
</style>
@@ -6,6 +6,7 @@
<RouterView
v-if="isRefresh"
:key="$route.path"
v-slot="{ Component, route }"
:style="{ minHeight: containerMinHeight }"
>
@@ -15,8 +16,9 @@
<!-- 路由动画 -->
<Transition :name="pageTransition" mode="out-in" appear>
<!-- <KeepAlive :max="10" :include="['StockingAdd', 'StockingFinalAdd']"> -->
<KeepAlive :max="10" :exclude="keepAliveExclude">
<component :is="Component" :key="route.path" v-if="route.meta.keepAlive" />
<component :is="Component" v-if="route.meta.keepAlive" />
</KeepAlive>
</Transition>
@@ -32,10 +34,11 @@
import { useWorktabStore } from '@/store/modules/worktab'
import { useSettingStore } from '@/store/modules/setting'
// Store refs
// Store refs1
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
const { keepAliveExclude } = storeToRefs(useWorktabStore())
const { containerMinHeight } = useCommon()
const containerStyle = computed(() => {
@@ -3,7 +3,10 @@
<div v-if="!isLock">
<el-dialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
<div class="lock-content">
<img class="cover" src="@imgs/user/avatar.webp" />
<img
class="cover"
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
/>
<div class="username">{{ userInfo.userName }}</div>
<el-form ref="formRef" :model="formData" :rules="rules" @submit.prevent="handleLock">
<el-form-item prop="password">
@@ -32,7 +35,10 @@
<div class="unlock-content" v-else>
<div class="box">
<img class="cover" src="@imgs/user/avatar.webp" />
<img
class="cover"
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
/>
<div class="username">{{ userInfo.userName }}</div>
<el-form
ref="unlockFormRef"
@@ -77,7 +83,7 @@
import { mittBus } from '@/utils/sys'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { getUserInfo } = useUserStore()
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
const userStore = useUserStore()
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
+11 -9
View File
@@ -14,6 +14,7 @@
v-loading="loading"
:data="tableData"
:row-key="rowKey"
:row-class-name="rowClassName"
:height="height"
:max-height="maxHeight"
:show-header="showHeader"
@@ -123,6 +124,8 @@
marginTop?: number
/** 表格大小 */
size?: 'small' | 'default' | 'large'
/** 行样式类名函数,透传给 el-table 的 row-class-name */
rowClassName?: (args: { row: any; rowIndex: number }) => string
}
const props = withDefaults(defineProps<TableProps>(), {
@@ -146,7 +149,8 @@
paginationSize: 'default',
paginationLayout: '',
showHeaderBackground: null,
marginTop: 20
marginTop: 20,
rowClassName: undefined
})
/*
@@ -184,12 +188,11 @@
const elTable = tableRef.value
if (!elTable) return
// 递归处理树形数据
// 支持普通与树形数据行展开
const processRows = (rows: any[]) => {
rows.forEach((row) => {
const hasChildren = row.children?.length > 0
if (hasChildren) {
elTable.toggleRowExpansion(row, true)
elTable.toggleRowExpansion(row, true)
if (row.children?.length > 0) {
processRows(row.children)
}
})
@@ -203,12 +206,11 @@
const elTable = tableRef.value
if (!elTable) return
// 递归处理树形数据
// 支持普通与树形数据行收起
const processRows = (rows: any[]) => {
rows.forEach((row) => {
const hasChildren = row.children?.length > 0
if (hasChildren) {
elTable.toggleRowExpansion(row, false)
elTable.toggleRowExpansion(row, false)
if (row.children?.length > 0) {
processRows(row.children)
}
})
@@ -4,6 +4,7 @@
<slot name="left"></slot>
</div>
<div class="right">
<slot name="right"></slot>
<div class="btn" @click="refresh">
<i class="iconfont-sys">&#xe614;</i>
</div>
@@ -68,7 +69,6 @@
}}</ElCheckbox>
</div>
</ElPopover>
<slot name="right"></slot>
</div>
</div>
</template>
@@ -11,9 +11,10 @@
</template>
<script setup lang="ts">
import { HOME_PAGE } from '@/router/routesAlias'
// import { HOME_PAGE } from '@/router/routesAlias'
import { useUserStore } from '@/store/modules/user'
const { home_page } = useUserStore()
const router = useRouter()
interface ExceptionData {
/** 标题 */
title: string
@@ -33,7 +34,7 @@
)
const backHome = () => {
router.push(HOME_PAGE)
router.push(home_page)
}
</script>
+142
View File
@@ -0,0 +1,142 @@
<template>
<div class="approval-timeline">
<div v-for="(rec, idx) in records" :key="idx" class="timeline-item">
<div class="left">
<div class="avatar-box">
<span class="initial">{{ rec.name?.slice(0, 1) || '—' }}</span>
<i v-if="rec.approved" class="status-dot iconfont-sys">&#xe621;</i>
<i v-if="rec.rejected" class="status-dot rejected">&#xe83a;</i>
<i v-else-if="rec.pending" class="status-dot pending iconfont-sys">&#xe839;</i>
</div>
</div>
<div class="right">
<div class="row name-role">
<span class="name">{{ rec.name }}</span>
<span class="role">({{ rec.role }})</span>
</div>
<div >
审核说明:{{ rec.comment }}
</div>
<div class="row time">{{ rec.time }}</div>
</div>
</div>
<div v-if="!records || !records.length" class="empty">暂无审批记录</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
interface ApprovalRecordItem {
name?: string
role?: string
type?: string
time?: string
approved?: boolean
rejected?: boolean
pending?: boolean
comment?: string
}
export default defineComponent({
name: 'ApprovalTimeline',
props: {
records: {
type: Array as () => ApprovalRecordItem[],
default: () => []
}
}
})
</script>
<style scoped lang="scss">
/* 审批记录 时间线 */
.approval-timeline {
padding: 8px 4px 8px 0;
}
.timeline-item {
display: flex;
align-items: flex-start;
gap: 10px;
position: relative;
}
.timeline-item + .timeline-item {
margin-top: 50px;
}
.left {
width: 46px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.avatar-box {
width: 46px;
height: 46px;
background: #3d7bff;
border-radius: 10px;
color: #fff;
position: relative;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 20px;
}
.status-dot {
position: absolute;
right: -4px;
bottom: -4px;
font-size: 9px;
padding: 3px 1px;
border-radius: 50%;
background: #22c55e;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.status-dot.pending {
background: #3471ff;
padding: 3px 3px;
}
.status-dot.rejected {
background: #ff0000;
padding: 3px 3px;
}
/* 连接下一个节点的虚线 */
.timeline-item:not(:last-child) .left::after {
content: '';
position: absolute;
top: 52px; /* avatar 46px + 间距 6px */
left: 23px; /* 盒子宽度一半,确保竖线居中 */
width: 0;
height: calc(100% + 16px); /* 加上相邻项之间的间距 */
border-left: 2px dotted #bfbfbf;
}
.right {
flex: 1;
padding-top: 2px;
}
.row.name-role {
color: #222;
margin-bottom: 10px;
}
.name {
font-weight: 600;
}
.role {
color: #666;
margin-left: 4px;
}
.time {
color: #888;
font-size: 12px;
margin-top: 2px;
margin-top: 10px;
}
.empty {
color: #999;
padding: 8px 0 4px 4px;
}
</style>
@@ -0,0 +1,554 @@
<template>
<el-dialog
v-model="visible"
title="详情"
width="800px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="payment-detail-dialog" v-loading="loading">
<div v-if="paymentData" class="detail-content">
<!-- 基本信息 -->
<div class="section">
<div class="section-title">
<span class="border"></span>
<span class="title">基本信息</span>
</div>
<div class="info-grid">
<div class="info-item">
<label>创建日期:</label>
<span>{{ formatDate(paymentData.created_at) }}</span>
</div>
<!-- <div class="info-item">
<label>付款来源:</label>
<span>{{ paymentData.country_name || '—' }}</span>
</div> -->
<div class="info-item">
<label>汇款人:</label>
<span>{{ paymentData.remitter || '—' }}</span>
</div>
<div class="info-item">
<label>账户:</label>
<span>{{ paymentData.account_name || '—' }}</span>
</div>
<div class="info-item">
<label>币种:</label>
<span>{{ paymentData.currency_name || '—' }}</span>
</div>
<div class="info-item">
<label>金额:</label>
<span class="amount">{{ paymentData.amount || '—' }}</span>
</div>
<div class="info-item">
<label>外汇到账回单:</label>
<span>
<el-image
v-if="paymentData.fx_receipt_file_full"
:src="paymentData.fx_receipt_file_full"
:preview-src-list="[paymentData.fx_receipt_file_full]"
style="width: 100px; height: 100px"
/>
</span>
</div>
</div>
</div>
<!-- 关联信息 -->
<div class="section" v-if="paymentData.payment_apply_id">
<div class="section-title">
<span class="border"></span>
<span class="title">关联信息</span>
</div>
<div class="info-grid">
<div class="info-item">
<label>付款申请ID:</label>
<span>{{ paymentData.payment_apply_id }}</span>
</div>
<div class="info-item">
<label>阿里订单号/PI号:</label>
<span>{{ paymentData.payment_apply.stock[0].pi_no }}</span>
</div>
<div class="info-item">
<label>业务员:</label>
<span>{{ paymentData.payment_apply.sales_user_name || '—' }}</span>
</div>
<div class="info-item">
<label>付款公司:</label>
<span>{{ paymentData.payment_apply.account_name || '—' }}</span>
</div>
<div class="info-item">
<label>付款来源:</label>
<span>{{ paymentData.payment_apply.country_name || '—' }}</span>
</div>
<div class="info-item">
<label>汇款人:</label>
<span>{{ paymentData.payment_apply.remitter || '—' }}</span>
</div>
<div class="info-item">
<label>账户:</label>
<span>{{ paymentData.payment_apply.account_short_desc || '—' }}</span>
</div>
<div class="info-item">
<label>币种:</label>
<span>{{ paymentData.payment_apply.currency_name || '—' }}</span>
</div>
<div class="info-item">
<label>汇率:</label>
<span>{{ paymentData.payment_apply.exchange_rate || '—' }}</span>
</div>
<div class="info-item">
<label>总金额:</label>
<span>{{ paymentData.payment_apply.amount || '—' }}</span>
</div>
<div class="info-item">
<label>汇款行:</label>
<span>{{ paymentData.payment_apply.swift || '—' }}</span>
</div>
<div class="info-item">
<label>客户付款水单:</label>
<span>
<el-image
v-if="paymentData.payment_apply.voucher_file_full"
:src="paymentData.payment_apply.voucher_file_full"
:preview-src-list="[paymentData.payment_apply.voucher_file_full]"
style="width: 100px; height: 100px"
/>
</span>
</div>
<div class="info-item">
<label>入账资料:</label>
<div v-if="paymentData.payment_apply.submit_files.length > 0" class="files-list">
<div
v-for="(file, index) in paymentData.payment_apply.submit_files"
:key="index"
class="file-item"
>
<div v-if="isImageFile(file.file_full)" class="image-preview">
<img
:src="file.file_full"
:alt="file.file_name"
class="preview-image"
@click="previewImage(file)"
/>
<div class="file-info">
<span class="file-name">{{ file.file_name }}</span>
<div class="file-actions">
<!-- <el-button type="text" size="small" @click="previewImage(file)">预览</el-button> -->
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
</div>
</div>
</div>
<div v-else class="document-preview">
<div class="file-icon">
<i class="iconfont-sys">&#xe814;</i>
</div>
<div class="file-info">
<span class="file-name">{{ file.file_name }}</span>
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 收款记录关联 -->
<div
class="section"
v-if="paymentData.receipt_records && paymentData.receipt_records.length"
>
<div class="section-title">
<span class="border"></span>
<span class="title">关联收款记录</span>
</div>
<el-table :data="paymentData.receipt_records" style="margin-top: 12px">
<el-table-column label="收款记录ID" prop="id" min-width="120" />
<el-table-column label="创建日期" prop="created_at" min-width="140">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="金额" prop="amount" min-width="120">
<template #default="scope">
{{ formatAmount(scope.row.amount, scope.row.currency_name) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="status" min-width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 备注信息 -->
<div class="section" v-if="paymentData.remark">
<div class="section-title">
<span class="border"></span>
<span class="title">备注信息</span>
</div>
<div class="remark-content">
{{ paymentData.remark }}
</div>
</div>
</div>
<!-- 无数据状态 -->
<div v-else-if="!loading" class="no-data">
<i class="iconfont-sys">&#xe60c;</i>
<span>暂无数据</span>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { financeService } from '@/api/admin/finance'
interface PaymentRecord {
id: string | number
created_at: string
country_name: string
remitter: string
account_name: string
currency_name: string
amount: number
status: string
payment_apply_id?: any
receipt_records?: any[]
payment_apply?: any
remark?: string
fx_receipt_file_full?: string
}
interface Props {
modelValue: boolean
paymentId?: string | number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const loading = ref(false)
const paymentData = ref<PaymentRecord | null>(null)
// 获取付款记录详情
const getPaymentDetail = async () => {
if (!props.paymentId) return
loading.value = true
try {
const res = await financeService.getReceiptDetail({ id: props.paymentId })
paymentData.value = res
} catch (error) {
console.error('获取付款记录详情失败:', error)
paymentData.value = null
} finally {
loading.value = false
}
}
// 关闭对话框
const handleClose = () => {
visible.value = false
paymentData.value = null
}
// 打印功能
const handlePrint = () => {
window.print()
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
try {
return new Date(dateStr).toLocaleString('zh-CN')
} catch {
return dateStr
}
}
// 格式化金额
const formatAmount = (amount: number, currency: string) => {
return `${currency || ''} ${amount?.toLocaleString() || 0}`
}
// 获取状态类型
const getStatusType = (status: string): 'success' | 'info' | 'warning' | 'danger' | 'primary' => {
const statusMap: Record<string, 'success' | 'info' | 'warning' | 'danger' | 'primary'> = {
available: 'success',
used: 'info',
pending: 'warning',
cancelled: 'danger',
bound: 'success',
unbound: 'info'
}
return statusMap[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
'available': '可用',
'used': '已使用',
'pending': '待处理',
'cancelled': '已取消',
'bound': '已绑定',
'unbound': '未绑定'
}
return statusMap[status] || status
}
// 判断是否为图片文件
const isImageFile = (file: any) => {
// 检查文件名
if (file.name && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(file.name)) {
return true
}
// 检查file_name字段
if (file.file_name && file.file_name.startsWith('image/')) {
return true
}
// 检查URL中的文件扩展名(去除查询参数)
if (file.url) {
const urlWithoutParams = file.url.split('?')[0] // 移除查询参数
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(urlWithoutParams)) {
return true
}
}
return false
}
// 预览图片
const previewImage = (file: any) => {
// 创建图片预览弹窗
const imagePreview = document.createElement('div')
imagePreview.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: pointer;
`
const image = document.createElement('img')
image.src = file.url
image.alt = file.name
image.style.cssText = `
max-width: 90%;
max-height: 90%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
`
const closeBtn = document.createElement('div')
closeBtn.innerHTML = '×'
closeBtn.style.cssText = `
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 30px;
cursor: pointer;
z-index: 10000;
`
imagePreview.appendChild(image)
imagePreview.appendChild(closeBtn)
document.body.appendChild(imagePreview)
// 点击关闭
const closePreview = () => {
document.body.removeChild(imagePreview)
}
imagePreview.addEventListener('click', closePreview)
closeBtn.addEventListener('click', (e) => {
e.stopPropagation()
closePreview()
})
// ESC键关闭
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closePreview()
document.removeEventListener('keydown', handleKeydown)
}
}
document.addEventListener('keydown', handleKeydown)
}
// 下载文件
const downloadFile = async (file: any) => {
try {
// 对所有文件类型都使用 fetch 下载,确保真正的下载
const response = await fetch(file.file_full, {
method: 'GET',
headers: {
Accept: '*/*'
}
})
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = file.name || file.file_name || 'download'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('下载成功')
} catch (error) {
console.error('下载失败:', error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
ElMessage.error(`下载失败: ${errorMessage}`)
}
}
// 监听对话框显示状态
watch(visible, (newVal) => {
if (newVal && props.paymentId) {
getPaymentDetail()
}
})
// 监听付款ID变化
watch(
() => props.paymentId,
(newId) => {
if (visible.value && newId) {
getPaymentDetail()
}
}
)
</script>
<style scoped lang="scss">
.payment-detail-dialog {
.detail-content {
.section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 16px;
font-weight: 600;
color: #222;
.border {
width: 4px;
height: 16px;
background: #3d7bff;
margin-right: 8px;
border-radius: 2px;
}
.title {
font-size: 16px;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
.info-item {
display: flex;
align-items: center;
//padding: 8px 0;
label {
font-weight: 500;
color: #666;
min-width: 100px;
margin-right: 8px;
}
span {
color: #333;
flex: 1;
&.amount {
font-weight: 600;
color: #3d7bff;
font-size: 16px;
}
}
}
}
.remark-content {
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
color: #666;
line-height: 1.6;
min-height: 60px;
}
}
}
.no-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: #999;
i {
font-size: 48px;
margin-bottom: 12px;
}
}
}
.dialog-footer {
display: flex;
justify-content: center;
gap: 12px;
}
// 打印样式
@media print {
.dialog-footer {
display: none;
}
.payment-detail-dialog {
.section-title {
.border {
background: #000 !important;
}
}
}
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<template>
<div class="file-preview-container">
<div class="form-title">
<span class="title">{{ title }}</span>
</div>
<div class="file-preview">
<div v-if="files.length > 0" class="files-list">
<div v-for="(file, index) in files" :key="index" class="file-item">
<div v-if="isImageFile(file)" class="image-preview">
<img
:src="file.url"
:alt="file.name"
class="preview-image"
@click="previewImage(file)"
/>
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<div class="file-actions">
<!-- <el-button type="text" size="small" @click="previewImage(file)">预览</el-button> -->
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
</div>
</div>
</div>
<div v-else class="document-preview">
<div class="file-icon">
<i class="iconfont-sys">&#xe814;</i>
</div>
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<el-button type="text" size="small" @click="previewFile(file)">预览</el-button>
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
</div>
</div>
</div>
</div>
<div v-else class="no-file">
<i class="iconfont-sys">&#xe60c;</i>
<span>{{ emptyText }}</span>
</div>
</div>
<files-previews v-model="fileshow" :file="filepviews" @close="onClose" />
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { ElMessage } from 'element-plus'
import FilesPreviews from '@/components/custom/files_previews.vue'
interface FileItem {
name: string
url: string
file_name: string
}
export default defineComponent({
name: 'FilePreview',
components: { FilesPreviews },
props: {
title: {
type: String,
required: true
},
files: {
type: Array as () => FileItem[],
default: () => []
},
emptyText: {
type: String,
default: '暂无文件'
}
},
setup() {
const fileshow = ref(false)
const filepviews = ref({} as FileItem)
function onClose() {
fileshow.value = false
console.log('文件预览已关闭')
}
// 判断是否为图片文件
const isImageFile = (file: FileItem) => {
// 检查文件名
if (file.name && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(file.name)) {
return true
}
// 检查file_name字段
if (file.file_name && file.file_name.startsWith('image/')) {
return true
}
// 检查URL中的文件扩展名(去除查询参数)
if (file.url) {
const urlWithoutParams = file.url.split('?')[0] // 移除查询参数
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(urlWithoutParams)) {
return true
}
}
return false
}
// 预览图片
const previewImage = (file: FileItem) => {
// 创建图片预览弹窗
const imagePreview = document.createElement('div')
imagePreview.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: pointer;
`
const image = document.createElement('img')
image.src = file.url
image.alt = file.name
image.style.cssText = `
max-width: 90%;
max-height: 90%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
`
const closeBtn = document.createElement('div')
closeBtn.innerHTML = '×'
closeBtn.style.cssText = `
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 30px;
cursor: pointer;
z-index: 10000;
`
imagePreview.appendChild(image)
imagePreview.appendChild(closeBtn)
document.body.appendChild(imagePreview)
// 点击关闭
const closePreview = () => {
document.body.removeChild(imagePreview)
}
imagePreview.addEventListener('click', closePreview)
closeBtn.addEventListener('click', (e) => {
e.stopPropagation()
closePreview()
})
// ESC键关闭
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closePreview()
document.removeEventListener('keydown', handleKeydown)
}
}
document.addEventListener('keydown', handleKeydown)
}
// 下载文件
const downloadFile = async (file: FileItem) => {
try {
// 对所有文件类型都使用 fetch 下载,确保真正的下载
const response = await fetch(file.url, {
method: 'GET',
headers: {
Accept: '*/*'
}
})
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = file.name || file.file_name || 'download'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('下载成功')
} catch (error) {
console.error('下载失败:', error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
ElMessage.error(`下载失败: ${errorMessage}`)
}
}
const previewFile = (file: FileItem) => {
filepviews.value = file
fileshow.value = true
}
return {
fileshow,
filepviews,
previewFile,
onClose,
isImageFile,
previewImage,
downloadFile
}
}
})
</script>
<style scoped lang="scss">
.file-preview-container {
.form-title {
font-weight: bold;
color: #222;
margin-bottom: 15px;
.title {
margin-left: 6px;
}
}
.no-file {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #999;
font-size: 14px;
}
.no-file i {
font-size: 48px;
color: #ddd;
}
.files-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.file-item {
flex: 0 0 auto;
min-width: 200px;
max-width: 300px;
}
.file-preview {
position: relative;
width: 100%;
max-height: 120px;
padding: 10px;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
background: #fafafa;
}
.image-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.preview-image {
flex: 1;
max-height: 70px;
object-fit: cover;
cursor: pointer;
transition: transform 0.2s;
}
.preview-image:hover {
transform: scale(1.05);
}
.document-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.file-icon {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.file-icon i {
font-size: 48px;
color: #409eff;
}
.file-info {
padding: 8px;
background: rgba(255, 255, 255, 0.9);
width: 100%;
display: flex;
// flex-direction: column;
align-items: center;
gap: 4px;
}
.file-actions {
display: flex;
gap: 8px;
align-items: center;
}
.file-name {
font-size: 12px;
color: #606266;
text-align: center;
word-break: break-all;
max-width: 100%;
}
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
title="内勤专用"
width="90%"
:close-on-click-modal="false"
>
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px">
<span>显示币种:</span>
<el-select v-model="selectedCurrency" placeholder="选择币种" style="width: 200px">
<el-option
v-for="opt in currencyOptions"
:key="opt.currency_code"
:label="opt.currency_name + ' (' + opt.currency_code + ')'"
:value="opt.currency_code"
/>
</el-select>
</div>
<div>
<el-table :data="displayData" style="width: 100%">
<el-table-column label="编号" width="80" align="center">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="model" label="车型" min-width="160" />
<el-table-column prop="qty" label="数量" width="80" />
<el-table-column prop="clearance_price" label="报关价" min-width="140">
<template #default="scope">{{ scope.row.clearance_price }}</template>
</el-table-column>
<el-table-column prop="fob_transport_fee" label="FOB运费" min-width="140">
<template #default="scope">{{ scope.row.fob_transport_fee }}</template>
</el-table-column>
<el-table-column prop="car_price" label="车价" min-width="140">
<template #default="scope">{{ scope.row.car_price }}</template>
</el-table-column>
<el-table-column prop="ocean_freight" label="海运费" min-width="140">
<template #default="scope">{{ scope.row.ocean_freight }}</template>
</el-table-column>
<el-table-column prop="commission" label="佣金" min-width="140">
<template #default="scope">{{ scope.row.commission }}</template>
</el-table-column>
<el-table-column prop="retention_price" label="挂账" min-width="140">
<template #default="scope">{{ scope.row.retention_price }}</template>
</el-table-column>
<el-table-column prop="port_misc_fee" label="港杂" min-width="140">
<template #default="scope">{{ scope.row.port_misc_fee }}</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { commonService } from '@/api/commonApi'
defineOptions({
name: 'InternalStaffView'
})
interface CurrencyOption {
currency_code: string
currency_name: string
rate?: number
exchange_rate?: number
}
interface VehicleData {
model_snapshot?: string
model?: string
qty?: number
clearance_price?: any
clearance_price_currency?: string
fob_transport_fee?: any
fob_fee_currency?: string
car_price?: any
car_price_currency?: string
ocean_freight?: any
ocean_freight_currency?: string
commission?: any
commission_currency?: string
retention_price?: any
retention_price_currency?: string
port_misc_fee?: any
port_fee_currency?: string
}
const props = defineProps<{
visible: boolean
vehicleData: VehicleData[]
defaultCurrency?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const selectedCurrency = ref('')
const currencyOptions = ref<CurrencyOption[]>([])
const loadCurrencyOptions = async () => {
try {
const res = await commonService.getCurrencySelect()
currencyOptions.value = res || []
} catch (e) {
console.error('获取币种失败', e)
}
}
const rateOf = (c?: string) => {
const found = (currencyOptions.value || []).find((x: any) => x.currency_code === c)
return Number(found?.rate || found?.exchange_rate || 0) || 0
}
const convert = (amount: any, fromCode?: string, toCode?: string): number => {
const val = amount === undefined || amount === null || amount === '' ? 0 : Number(amount)
if (Number.isNaN(val)) return 0
const from = fromCode || 'USD'
const to = toCode || 'USD'
if (from === to) return val
const rf = rateOf(from)
const rt = rateOf(to) || 1
if (!rf) return 0
return (val * rf) / rt
}
const fmt = (n: number, cur: string) => `${n.toFixed(2)} ${cur}`
const displayData = computed(() => {
const code = selectedCurrency.value
return (props.vehicleData || []).map((it: VehicleData) => {
return {
model: it.model_snapshot || it.model || '',
qty: it.qty || 1,
clearance_price: fmt(
convert(
it.clearance_price?.amount ?? it.clearance_price,
it.clearance_price?.currency || it.clearance_price_currency,
code
),
code
),
fob_transport_fee: fmt(
convert(
it.fob_transport_fee?.amount ?? it.fob_transport_fee,
it.fob_transport_fee?.currency || it.fob_fee_currency || it.ocean_freight_currency,
code
),
code
),
car_price: fmt(
convert(
it.car_price?.amount ?? it.car_price,
it.car_price?.currency || it.car_price_currency,
code
),
code
),
ocean_freight: fmt(
convert(
it.ocean_freight?.amount ?? it.ocean_freight,
it.ocean_freight?.currency || it.ocean_freight_currency,
code
),
code
),
commission: fmt(
convert(
it.commission?.amount ?? it.commission,
it.commission?.currency || it.commission_currency,
code
),
code
),
retention_price: fmt(
convert(
it.retention_price?.amount ?? it.retention_price,
it.retention_price?.currency || it.retention_price_currency,
code
),
code
),
port_misc_fee: fmt(
convert(
it.port_misc_fee?.amount ?? it.port_misc_fee,
it.port_misc_fee?.currency || it.port_fee_currency,
code
),
code
)
}
})
})
// 监听 visible 变化,加载币种选项
watch(
() => props.visible,
(newVisible) => {
if (newVisible) {
loadCurrencyOptions()
// 设置默认币种
if (props.defaultCurrency) {
selectedCurrency.value = props.defaultCurrency
} else {
selectedCurrency.value = 'USD'
}
}
}
)
</script>
@@ -0,0 +1,210 @@
<template>
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px">
<span>显示币种:</span>
<el-select v-model="selectedCurrency" placeholder="选择币种" style="width: 200px">
<el-option
v-for="opt in currencyOptions"
:key="opt.currency_code"
:label="opt.currency_name + ' (' + opt.currency_code + ')'"
:value="opt.currency_code"
/>
</el-select>
</div>
<div>
<el-table :data="displayData" style="width: 100%">
<el-table-column label="编号" width="80" align="center">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="model" label="车型" min-width="160" />
<el-table-column prop="qty" label="数量" width="80" />
<el-table-column prop="clearance_price" label="报关价" min-width="140">
<template #default="scope">{{ scope.row.clearance_price }}</template>
</el-table-column>
<el-table-column prop="fob_transport_fee" label="FOB运费" min-width="140">
<template #default="scope">{{ scope.row.fob_transport_fee }}</template>
</el-table-column>
<el-table-column prop="car_price" label="车价" min-width="140">
<template #default="scope">{{ scope.row.car_price }}</template>
</el-table-column>
<el-table-column prop="ocean_freight" label="海运费" min-width="140">
<template #default="scope">{{ scope.row.ocean_freight }}</template>
</el-table-column>
<el-table-column prop="commission" label="佣金" min-width="140">
<template #default="scope">{{ scope.row.commission }}</template>
</el-table-column>
<el-table-column prop="retention_price" label="挂账" min-width="140">
<template #default="scope">{{ scope.row.retention_price }}</template>
</el-table-column>
<el-table-column prop="port_misc_fee" label="港杂" min-width="140">
<template #default="scope">{{ scope.row.port_misc_fee }}</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { commonService } from '@/api/commonApi'
defineOptions({
name: 'InternalStaffViews'
})
interface CurrencyOption {
currency_code: string
currency_name: string
rate?: number
exchange_rate?: number
}
interface VehicleData {
model_snapshot?: string
model?: string
qty?: number
clearance_price?: any
clearance_price_currency?: string
fob_transport_fee?: any
fob_fee_currency?: string
car_price?: any
car_price_currency?: string
ocean_freight?: any
ocean_freight_currency?: string
commission?: any
commission_currency?: string
retention_price?: any
retention_price_currency?: string
port_misc_fee?: any
port_fee_currency?: string
}
const props = defineProps<{
visible: boolean
vehicleData: VehicleData[]
defaultCurrency?: string
}>()
// const emit = defineEmits<{
// 'update:visible': [value: boolean]
// }>()
const selectedCurrency = ref('')
const currencyOptions = ref<CurrencyOption[]>([])
const loadCurrencyOptions = async () => {
try {
const res = await commonService.getCurrencySelect()
currencyOptions.value = res || []
} catch (e) {
console.error('获取币种失败', e)
}
}
const rateOf = (c?: string) => {
const found = (currencyOptions.value || []).find((x: any) => x.currency_code === c)
return Number(found?.rate || found?.exchange_rate || 0) || 0
}
const convert = (amount: any, fromCode?: string, toCode?: string): number => {
const val = amount === undefined || amount === null || amount === '' ? 0 : Number(amount)
if (Number.isNaN(val)) return 0
const from = fromCode || 'USD'
const to = toCode || 'USD'
if (from === to) return val
const rf = rateOf(from)
const rt = rateOf(to) || 1
if (!rf) return 0
return (val * rf) / rt
}
const fmt = (n: number, cur: string) => `${n.toFixed(2)} ${cur}`
const displayData = computed(() => {
const code = selectedCurrency.value
return (props.vehicleData || []).map((it: VehicleData) => {
return {
model: it.model_snapshot || it.model || '',
qty: it.qty || 1,
clearance_price: fmt(
convert(
it.clearance_price?.amount ?? it.clearance_price,
it.clearance_price?.currency || it.clearance_price_currency,
code
),
code
),
fob_transport_fee: fmt(
convert(
it.fob_transport_fee?.amount ?? it.fob_transport_fee,
it.fob_transport_fee?.currency || it.fob_fee_currency || it.ocean_freight_currency,
code
),
code
),
car_price: fmt(
convert(
it.car_price?.amount ?? it.car_price,
it.car_price?.currency || it.car_price_currency,
code
),
code
),
ocean_freight: fmt(
convert(
it.ocean_freight?.amount ?? it.ocean_freight,
it.ocean_freight?.currency || it.ocean_freight_currency,
code
),
code
),
commission: fmt(
convert(
it.commission?.amount ?? it.commission,
it.commission?.currency || it.commission_currency,
code
),
code
),
retention_price: fmt(
convert(
it.retention_price?.amount ?? it.retention_price,
it.retention_price?.currency || it.retention_price_currency,
code
),
code
),
port_misc_fee: fmt(
convert(
it.port_misc_fee?.amount ?? it.port_misc_fee,
it.port_misc_fee?.currency || it.port_fee_currency,
code
),
code
)
}
})
})
loadCurrencyOptions()
// 设置默认币种
if (props.defaultCurrency) {
selectedCurrency.value = props.defaultCurrency
} else {
selectedCurrency.value = 'USD'
}
// 监听 visible 变化,加载币种选项
// watch(
// () => props.visible,
// (newVisible) => {
// if (newVisible) {
// loadCurrencyOptions()
// // 设置默认币种
// if (props.defaultCurrency) {
// selectedCurrency.value = props.defaultCurrency
// } else {
// selectedCurrency.value = 'USD'
// }
// }
// }
// )
</script>
@@ -0,0 +1,732 @@
<template>
<div class="order-detail-content">
<!-- 基本信息 -->
<div class="basic-info-section">
<div class="basic-info-item">
<span class="label">备货单编号:</span>
<span class="value">{{ orderData.order_no || orderData.orderNo || '—' }}</span>
</div>
<div class="basic-info-item">
<span class="label">业务员:</span>
<span class="value">{{
orderData.salesperson_snapshot || orderData.salesperson || '—'
}}</span>
</div>
</div>
<!-- 客户信息 -->
<div class="customer-info-table">
<table class="info-table">
<tr>
<td>
<span class="info-label"> 阿里订单号/PI号:</span>
<span class="info-value">{{ orderData.pi_no || orderData.piNo || '—' }}</span>
</td>
<td>
<span class="info-label"> 日期:</span>
<span class="info-value">{{ orderData.created_at || orderData.date || '—' }}</span>
</td>
<td>
<span class="info-label"> 清关目的国:</span>
<span class="info-value">{{
orderData.clearance_country_snapshot || orderData.clearanceCountry || '—'
}}</span>
</td>
<td>
<span class="info-label"> 客户来源:</span>
<span class="info-value">{{
orderData.customer_source_snapshot || orderData.customerSource || '—'
}}</span>
</td>
<td>
<span class="info-label"> 客户名称:</span>
<span class="info-value">{{
orderData.customer_snapshot || orderData.customerName || '—'
}}</span>
</td>
<td>
<span class="info-label"> 客户所在国:</span>
<span class="info-value">{{
orderData.customer_country_snapshot || orderData.customerCountry || '—'
}}</span>
</td>
</tr>
</table>
</div>
<!-- 订单详情表格(ArtTable) -->
<div class="table-section">
<ArtTable ref="tableRef" :data="vehItems" border :pagination="false">
<template #default>
<ElTableColumn type="expand" width="30">
<template #default="scope">
<div style="padding: 6px">
<div style="display: flex">
<div><strong>备注:</strong></div>
<div><span v-html="scope.row.remark || '暂无备注'"></span></div>
</div>
<div
v-if="getItemAttachments(scope.row).length"
style="display: flex; margin-top: 6px"
>
<div><strong>附件:</strong></div>
<div style="display: flex; flex-wrap: wrap; gap: 12px">
<a
v-for="(f, aIdx) in getItemAttachments(scope.row)"
:key="aIdx"
:href="f.url"
target="_blank"
rel="noopener"
style="color: #3471ff; text-decoration: none"
>
<i class="iconfont-sys">&#xe814;</i> {{ f.name }}
</a>
</div>
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="编号" width="60" align="center">
<template #default="scope">{{ scope.$index + 1 }}</template>
</ElTableColumn>
<ElTableColumn label="车型" width="160" align="center">
<template #default="scope">
<div>{{ scope.row.model_snapshot || scope.row.model || '—' }}</div>
<div v-if="scope.row.mileage > 0">里程:{{ scope.row.mileage }}km</div>
<div v-if="scope.row.vin && scope.row.vin != ''">VIN: {{ scope.row.vin }}</div>
</template>
</ElTableColumn>
<ElTableColumn label="数量/台" width="90" align="center">
<template #default="scope">{{ scope.row.num ?? scope.row.quantity ?? 1 }}</template>
</ElTableColumn>
<ElTableColumn label="颜色" width="120" align="center">
<template #default="scope">
<div> 外饰:{{ scope.row.color_exterior_snapshot || '—' }} </div>
<div> 内饰:{{ scope.row.color_interior_snapshot || '—' }} </div>
</template>
</ElTableColumn>
<ElTableColumn label="费用" min-width="200" align="center">
<template #default="scope">
<ElRow :gutter="12" class="item-row-wrap">
<ElCol :span="8" class="item-row">
<span>官方指导价/二手挂牌价:</span>
<span v-if="scope.row.mileage > 0" class="price-num">
{{
scope.row.listing_price?.currency_info ||
scope.row.listing_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
<span v-else class="price-num">
{{
scope.row.official_price?.currency_info ||
scope.row.official_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>优惠:</span>
<span v-if="scope.row.mileage > 0" class="price-num">
{{
scope.row.reduction_price?.currency_info ||
scope.row.reduction_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
<span v-else class="price-num">
{{
scope.row.discount_price?.currency_info ||
scope.row.discount_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>手续费:</span>
<span class="price-num">
{{
scope.row.handling_fee?.currency_info ||
scope.row.handling_fee?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
</ElRow>
<ElRow :gutter="12" class="item-row-wrap">
<ElCol :span="8" class="item-row">
<span>加价:</span>
<span class="price-num">
{{
scope.row.increase_price?.currency_info ||
scope.row.increase_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>改装费:</span>
<span class="price-num">
{{
scope.row.retrofit_fee?.currency_info ||
scope.row.retrofit_fee?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>配件费:</span>
<span class="price-num">
{{
scope.row.accessory_price?.currency_info ||
scope.row.accessory_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
</ElRow>
<ElRow :gutter="12" class="item-row-wrap">
<ElCol :span="8" class="item-row">
<span>FOB运费:</span>
<span class="price-num">
{{
scope.row.fob_transport_fee?.currency_info ||
scope.row.fob_transport_fee?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>港杂费:</span>
<span class="price-num">
{{
scope.row.port_misc_fee?.currency_info ||
scope.row.port_misc_fee?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>海运费:</span>
<span class="price-num">
{{
scope.row.ocean_freight?.currency_info ||
scope.row.ocean_freight?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
</ElRow>
<ElRow :gutter="12" class="item-row-wrap">
<ElCol :span="8" class="item-row">
<span>佣金:</span>
<span class="price-num">
{{
scope.row.commission?.currency_info ||
scope.row.commission?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>挂账:</span>
<span class="price-num">
{{
scope.row.retention_price?.currency_info ||
scope.row.retention_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>购置税:</span>
<span class="price-num">
{{
scope.row.purchase_tax?.currency_info ||
scope.row.purchase_tax?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
</ElRow>
<ElRow :gutter="12" class="item-row-wrap">
<ElCol :span="8" class="item-row">
<span>预估退税额:</span>
<span class="price-num">
{{ scope.row.tax_refund?.currency_info || scope.row.tax_refund?.amount || '—' }}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>车价:</span>
<span class="price-num">
{{ scope.row.car_price?.currency_info || scope.row.car_price?.amount || '—' }}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>收款价:</span>
<span class="price-num">
{{
scope.row.unit_price?.currency_info ||
scope.row.unit_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
</ElRow>
<ElRow :gutter="12" class="item-row-wrap">
<ElCol :span="8" class="item-row">
<span>总价:</span>
<span class="price-num">
{{
scope.row.total_price?.currency_info ||
scope.row.total_price?.amount ||
scope.row.discount ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row">
<span>报关价:</span>
<span class="price-num">
{{
scope.row.clearance_price?.currency_info ||
scope.row.clearance_price?.amount ||
scope.row.clearance_price ||
'—'
}}
</span>
</ElCol>
<ElCol :span="8" class="item-row"></ElCol>
</ElRow>
</template>
</ElTableColumn>
<ElTableColumn fixed="right" label="利润" width="150" align="center">
<template #default="scope">{{
scope.row.profit?.currency_info ||
scope.row.profit?.amount ||
scope.row.discount ||
'—'
}}</template>
</ElTableColumn>
</template>
</ArtTable>
<div class="order-summary">
<div class="summary-item">
<span class="label">合同汇率:</span>
<span class="value">{{
orderData.contract_exchange_rate || orderData.contractRate || '—'
}}</span>
</div>
<div class="summary-item">
<span class="label">合计到岸价:</span>
<span class="value"> {{ orderData.cif_total?.currency_info || '—' }}</span>
</div>
<div class="summary-item">
<span class="label">利润:</span>
<span class="value">{{
orderData.profit_total?.currency_info || orderData.profit_total?.amount || '—'
}}</span>
</div>
</div>
</div>
<!-- 物流信息 -->
<div class="customer-info-table">
<table class="info-table">
<tr>
<td>
<span class="info-label"> 清关人:</span>
<span class="info-value">{{
orderData.declarant_name || orderData.declarant || '—'
}}</span>
</td>
<td v-if="orderData.is_final == 0">
<span class="info-label"> 预计发运日期:</span>
<span class="info-value">{{
orderData.eta_ship_date || orderData.etaShipDate || '—'
}}</span>
</td>
<td v-else>
<span class="info-label"> 全款收齐日:</span>
<span class="info-value">{{ orderData.collect_all_date || '—' }}</span>
</td>
<td>
<span class="info-label"> 出运方式:</span>
<span class="info-value">{{
orderData.shipping_method_snapshot || orderData.shippingMethod || '—'
}}</span>
</td>
<td>
<span class="info-label"> 国内收货港:</span>
<span class="info-value">{{
orderData.domestic_port_snapshot || orderData.domesticPort || '—'
}}</span>
</td>
<td>
<span class="info-label"> 贸易条款:</span>
<span class="info-value">{{
orderData.trade_term_snapshot || orderData.tradeTerms || '—'
}}</span>
</td>
<td>
<span class="info-label"> 清关目的港:</span>
<span class="info-value">{{ orderData.clearance_port_snapshot || '—' }}</span>
</td>
<td v-if="orderData.is_final == 0">
<span class="info-label"> 最迟到港时间:</span>
<span class="info-value">{{
orderData.latest_arrival_date || orderData.latestArrival || '—'
}}</span>
</td>
<td v-else>
<span class="info-label"> 提车日期:</span>
<span class="info-value">{{ orderData.pick_up_date || '—' }}</span>
</td>
</tr>
</table>
</div>
<!-- 计划收款信息 -->
<div
class="payment-section"
v-if="
(orderData.account_list && orderData.account_list.length) ||
(orderData.payments && orderData.payments.length)
"
>
<div class="table-wrapper">
<table class="payment-table">
<thead>
<tr>
<th style="width: 140px"></th>
<th>序号</th>
<th>汇款国</th>
<th>日期</th>
<th>币种</th>
<th>账户</th>
<th>汇款人</th>
<th>金额</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in paymentTableData" :key="index">
<td v-if="index === 0" :rowspan="paymentTableData.length">计划收款信息</td>
<td>{{ row.serial }}</td>
<td>{{ row.country }}</td>
<td>{{ row.date }}</td>
<td>{{ row.currency }}</td>
<td>{{ row.account }}</td>
<td>{{ row.remitter }}</td>
<td>{{ row.amount }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref, onMounted, nextTick, watch } from 'vue'
export default defineComponent({
name: 'OrderDetailContent',
props: {
orderData: {
type: Object as () => any,
default: () => ({})
}
},
setup(props) {
const paymentTableData = computed(() => {
const list = (props.orderData as any).account_list
? (props.orderData as any).account_list.map((p: any, idx: number) => ({
group: '计划收款信息',
serial: `收款${idx + 1}`,
country: p.remit_country_name_snapshot || '—',
date: p.remit_date || '—',
currency: p.currency_name_snapshot || '—',
account: p.account_name_snapshot || '—',
remitter: p.remitter_name || '—',
amount: p.amount || '—'
}))
: (props.orderData as any).payments || []
if (!list.length) return []
return list
})
const formatPrice = (price?: number): string => {
if (price === undefined || price === null) return '0.00'
return Number(price).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const vehItems = computed(() => {
return (props.orderData as any).veh_list || (props.orderData as any).items || []
})
// 车辆附件(后端返回数组;兼容历史字符串数据)
const getItemAttachments = (row: any) => {
const raw = row?.attachment
let list: any[] = []
if (Array.isArray(raw)) {
list = raw
} else if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) list = parsed
} catch {
list = []
}
}
return list
.filter((f: any) => f && f.url)
.map((f: any) => ({ name: f.original_name || f.name || '附件', url: f.url }))
}
const tableRef = ref()
const expandWhenReady = () => {
nextTick(() => {
nextTick(() => {
if (vehItems.value && vehItems.value.length) {
tableRef.value?.expandAll?.()
}
})
})
}
onMounted(() => {
// 默认展开所有行(等数据和表格渲染完成后再触发)
expandWhenReady()
})
watch(
() => vehItems.value?.length,
(len) => {
if (len && len > 0) expandWhenReady()
},
{ immediate: true }
)
return {
paymentTableData,
vehItems,
formatPrice,
tableRef,
getItemAttachments
}
}
})
</script>
<style scoped lang="scss">
.order-detail-content {
font-family: 'Microsoft YaHei', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
color: #333;
}
.basic-info-section {
display: flex;
justify-content: center;
gap: 40px;
margin-bottom: 20px;
}
.basic-info-item {
display: flex;
align-items: center;
.label {
font-weight: 600;
color: #333;
margin-right: 8px;
white-space: nowrap;
}
.value {
color: #666;
}
}
.customer-info-table {
margin: 20px 0px;
}
.info-table {
width: 100%;
border-collapse: collapse;
background: white;
td {
padding: 12px 12px;
border: 1px solid #d9d9d9;
font-size: 13px;
}
.info-value {
font-weight: 600;
color: #333;
text-align: right;
}
.info-label {
color: #666;
padding-right: 20px;
}
}
.table-section {
margin: 0;
}
.table-wrapper {
overflow-x: auto;
border: 1px solid #e4e7ed;
}
.order-table {
width: 100%;
border-collapse: collapse;
background: white;
font-size: 13px;
th {
background: #f5f7fa;
color: #333;
font-weight: bold;
padding: 12px 8px;
text-align: center;
border: 1px solid #d9d9d9;
white-space: nowrap;
font-size: 13px;
}
td {
padding: 10px 8px;
text-align: center;
border: 1px solid #d9d9d9;
color: #333;
font-size: 13px;
}
.col-no {
width: 60px;
}
.col-model {
max-width: 160px;
// text-align: left;
}
tbody tr:hover {
background: #e6f7ff;
}
}
.payment-section {
margin: 20px 0;
}
/* 订单底部汇总样式 */
.order-summary {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-top: none;
border-radius: 0 0 4px 4px;
flex-wrap: wrap;
}
.order-summary .summary-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
// background: #fff;
// border: 1px solid #e5e7eb;
border-radius: 4px;
white-space: nowrap;
}
.order-summary .label {
color: #666;
}
.order-summary .value {
font-weight: 600;
color: #111;
}
/* ArtTable 表头背景色 */
.table-section :deep(.el-table__header th) {
background-color: #f5f7fa;
color: #333;
font-weight: 600;
}
.table-section :deep(.el-table__header .cell) {
padding: 12px 8px;
}
.payment-table {
width: 100%;
border-collapse: collapse;
background: white;
font-size: 13px;
}
.payment-table th {
background: #f5f7fa;
color: #333;
font-weight: 600;
text-align: center;
border: 1px solid #d9d9d9;
padding: 12px 8px;
white-space: nowrap;
}
.payment-table td {
text-align: center;
color: #333;
border: 1px solid #d9d9d9;
padding: 10px 8px;
vertical-align: middle;
}
.el-table__expand-cell {
padding: 0;
border-top: none;
}
.el-table__expand-wrapper {
padding: 10px;
background-color: #f9f9f9;
}
.item-row-wrap {
margin: 0 !important;
}
.item-row-wrap .item-row {
border: 1px solid #e0e0e0;
padding: 5px 0;
// min-height: 40px;
text-align: left;
}
.price-num {
font-weight: bold;
color: #000;
margin-left: 5px;
}
/* 打印优化 */
@media print {
.order-detail-content {
font-size: 12px;
}
.order-table {
page-break-inside: avoid;
}
}
</style>
@@ -0,0 +1,380 @@
<template>
<el-dialog
v-model="visible"
title="付款记录详情"
width="800px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="payment-detail-dialog" v-loading="loading">
<div v-if="paymentData" class="detail-content">
<!-- 基本信息 -->
<div class="section">
<div class="section-title">
<span class="border"></span>
<span class="title">基本信息</span>
</div>
<div class="info-grid">
<div class="info-item">
<label>创建日期:</label>
<span>{{ formatDate(paymentData.created_at) }}</span>
</div>
<div class="info-item">
<label>付款来源:</label>
<span>{{ paymentData.country_name || '—' }}</span>
</div>
<div class="info-item">
<label>汇款人:</label>
<span>{{ paymentData.remitter || '—' }}</span>
</div>
<div class="info-item">
<label>账户:</label>
<span>{{ paymentData.account_name || '—' }}</span>
</div>
<div class="info-item">
<label>币种:</label>
<span>{{ paymentData.currency_name || '—' }}</span>
</div>
<div class="info-item">
<label>金额:</label>
<span class="amount">{{ paymentData.amount }}</span>
</div>
<div class="info-item">
<label>付款水单:</label>
<div>
<p v-for="(item, index) in paymentData.voucher_file" :key="index">
<el-image
v-if="
/\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(
(item.file_full || '').split('?')[0]
)
"
:src="item.file_full"
:preview-src-list="item.file_full ? [item.file_full] : []"
style="width: 100px; height: 100px"
/>
<a
v-else
:href="item.file_full"
target="_blank"
rel="noopener"
style="
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 200px;
"
>{{ item.file_name }}</a
>
</p>
</div>
</div>
</div>
</div>
<!-- 关联信息 -->
<div class="section" v-if="paymentData.payment_apply_id">
<div class="section-title">
<span class="border"></span>
<span class="title">关联信息</span>
</div>
<div class="info-grid">
<div class="info-item">
<label>付款申请ID:</label>
<span>{{ paymentData.payment_apply_id }}</span>
</div>
<div class="info-item">
<label>关联状态:</label>
<el-tag :type="getStatusType(paymentData.status)">
{{ getStatusText(paymentData.status) }}
</el-tag>
</div>
</div>
</div>
<!-- 收款记录关联 -->
<div
class="section"
v-if="paymentData.receipt_records && paymentData.receipt_records.length"
>
<div class="section-title">
<span class="border"></span>
<span class="title">关联收款记录</span>
</div>
<el-table :data="paymentData.receipt_records" style="margin-top: 12px">
<el-table-column label="收款记录ID" prop="id" min-width="120" />
<el-table-column label="创建日期" prop="created_at" min-width="140">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="金额" prop="amount" min-width="120">
<template #default="scope">
{{ formatAmount(scope.row.amount, scope.row.currency_name) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="status" min-width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 备注信息 -->
<div class="section" v-if="paymentData.remark">
<div class="section-title">
<span class="border"></span>
<span class="title">备注信息</span>
</div>
<div class="remark-content">
{{ paymentData.remark }}
</div>
</div>
</div>
<!-- 无数据状态 -->
<div v-else-if="!loading" class="no-data">
<i class="iconfont-sys">&#xe60c;</i>
<span>暂无数据</span>
</div>
</div>
<!-- <div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div> -->
</el-dialog>
</template>
<script setup lang="ts">
import { financeService } from '@/api/admin/finance'
interface PaymentRecord {
id: string | number
created_at: string
country_name: string
remitter: string
account_name: string
currency_name: string
amount: number
status: string
payment_apply_id?: string | number
receipt_records?: any[]
voucher_file?: any[]
submit_files?: string[]
remark?: string
}
interface Props {
modelValue: boolean
paymentId?: string | number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const loading = ref(false)
const paymentData = ref<PaymentRecord | null>(null)
// 获取付款记录详情
const getPaymentDetail = async () => {
if (!props.paymentId) return
loading.value = true
try {
const res = await financeService.getPaymentDetail({ id: props.paymentId })
paymentData.value = res
} catch (error) {
console.error('获取付款记录详情失败:', error)
paymentData.value = null
} finally {
loading.value = false
}
}
// 关闭对话框
const handleClose = () => {
visible.value = false
paymentData.value = null
}
// 打印功能
const handlePrint = () => {
window.print()
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
try {
return new Date(dateStr).toLocaleString('zh-CN')
} catch {
return dateStr
}
}
// 格式化金额
const formatAmount = (amount: number, currency: string) => {
return `${currency || ''} ${amount?.toLocaleString() || 0}`
}
// 获取状态类型
const getStatusType = (status: string): 'success' | 'info' | 'warning' | 'danger' | 'primary' => {
const statusMap: Record<string, 'success' | 'info' | 'warning' | 'danger' | 'primary'> = {
available: 'success',
used: 'info',
pending: 'warning',
cancelled: 'danger',
bound: 'success',
unbound: 'info'
}
return statusMap[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
'available': '可用',
'used': '已使用',
'pending': '待处理',
'cancelled': '已取消',
'bound': '已绑定',
'unbound': '未绑定'
}
return statusMap[status] || status
}
// 监听对话框显示状态
watch(visible, (newVal) => {
if (newVal && props.paymentId) {
getPaymentDetail()
}
})
// 监听付款ID变化
watch(() => props.paymentId, (newId) => {
if (visible.value && newId) {
getPaymentDetail()
}
})
</script>
<style scoped lang="scss">
.payment-detail-dialog {
.detail-content {
.section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 16px;
font-weight: 600;
color: #222;
.border {
width: 4px;
height: 16px;
background: #3d7bff;
margin-right: 8px;
border-radius: 2px;
}
.title {
font-size: 16px;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
.info-item {
display: flex;
align-items: center;
// padding: 8px 0;
label {
font-weight: 500;
color: #666;
min-width: 100px;
margin-right: 8px;
}
span {
color: #333;
flex: 1;
&.amount {
font-weight: 600;
color: #3d7bff;
font-size: 16px;
}
}
}
}
.remark-content {
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
color: #666;
line-height: 1.6;
min-height: 60px;
}
}
}
.no-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: #999;
i {
font-size: 48px;
margin-bottom: 12px;
}
}
}
.dialog-footer {
display: flex;
justify-content: center;
gap: 12px;
}
// 打印样式
@media print {
.dialog-footer {
display: none;
}
.payment-detail-dialog {
.section-title {
.border {
background: #000 !important;
}
}
}
}
</style>
@@ -0,0 +1,298 @@
<template>
<el-dialog
v-model="visible"
title="选择付款记录"
width="80%"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="payment-record-dialog">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="请输入汇款人、账户或金额"
clearable
style="width: 300px"
@keyup.enter="getPaymentRecords"
>
<template #suffix>
<el-icon class="clickable" @click="getPaymentRecords"><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="getPaymentRecords">搜索</el-button>
</div>
<!-- 付款记录表格 -->
<el-table
ref="tableRef"
:data="paymentRecords"
:loading="loading"
row-key="id"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
highlight-current-row
style="margin-top: 16px"
>
<el-table-column label="创建日期" prop="created_at" min-width="160" />
<el-table-column label="付款来源" prop="country_name" min-width="120" />
<el-table-column label="汇款人" prop="remitter" min-width="120" />
<el-table-column label="账户" prop="account_name" min-width="120" />
<el-table-column label="账币" prop="currency_name" min-width="120" />
<el-table-column label="金额" prop="amount" min-width="120">
<template #default="scope">
{{ formatAmount(scope.row.amount, scope.row.currency_name) }}
</template>
</el-table-column>
<el-table-column label="选择" width="80">
<template #default="scope">
<el-checkbox
v-model="checkboxStates[scope.row.id]"
@change="(val) => handleCheckboxChange(scope.row, !!val)"
@click.stop
/>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selectedRecord" @click="handleConfirm">
确认绑定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { financeService } from '@/api/admin/finance'
import { ElMessage } from 'element-plus'
interface PaymentRecord {
id: string | number
created_at: string
country_name: string
remitter: string
account_name: string
currency_name: string
amount: number
status: string
}
interface Props {
modelValue: boolean
receiptId?: string | number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', record: PaymentRecord): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const loading = ref(false)
const searchKeyword = ref('')
// const paymentRecords = ref<PaymentRecord[]>([])
const paymentRecords = ref<any[]>([])
const selectedRecord = ref<PaymentRecord | null>(null)
// const selectedRecord = ref<any[]>([])
const selectedRecordId = ref<any>(null)
const checkboxStates = reactive<Record<string | number, boolean>>({})
const tableRef = ref()
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 获取付款记录列表
const getPaymentRecords = async () => {
loading.value = true
try {
const params = {
current: pagination.currentPage,
size: pagination.pageSize,
keyword: searchKeyword.value,
scene: 'correlation',
receipt_id: props.receiptId,
correlation: 'unbind' // 只显示可用的付款记录
}
const { records, current, size, total } = await financeService.getPaymentList(params)
paymentRecords.value = records || []
// 初始化 checkbox 状态
records?.forEach((record: any) => {
checkboxStates[record.id] = false
})
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取付款记录失败:', error)
ElMessage.error('获取付款记录失败')
} finally {
loading.value = false
}
}
// 处理选择变化(保留兼容性,单选模式下不使用此方法)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleSelectionChange = (_selection: PaymentRecord[]) => {
// 单选模式下不使用此方法
}
// 处理 checkbox 变化(单选逻辑)
const handleCheckboxChange = (record: PaymentRecord, checked: boolean) => {
if (checked) {
// 先清除所有选中状态(单选逻辑)
Object.keys(checkboxStates).forEach((id) => {
checkboxStates[id] = false
})
// 选中当前项
checkboxStates[record.id] = true
selectedRecord.value = record
selectedRecordId.value = record.id
} else {
// 取消选中
selectedRecord.value = null
selectedRecordId.value = null
}
}
// 处理行点击
const handleRowClick = (record: PaymentRecord) => {
// 如果点击的是已选中的项,则取消选中;否则选中该项
if (selectedRecordId.value === record.id) {
// 取消选中
selectedRecord.value = null
selectedRecordId.value = null
checkboxStates[record.id] = false
} else {
// 先清除所有选中状态
Object.keys(checkboxStates).forEach((id) => {
checkboxStates[id] = false
})
// 选中当前项
selectedRecord.value = record
selectedRecordId.value = record.id
checkboxStates[record.id] = true
}
}
// 分页处理
const handleSizeChange = (size: number) => {
pagination.pageSize = size
pagination.currentPage = 1
getPaymentRecords()
}
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
getPaymentRecords()
}
// 确认绑定
const handleConfirm = () => {
if (!selectedRecord.value) {
ElMessage.warning('请选择一条付款记录')
return
}
emit('confirm', selectedRecord.value)
handleClose()
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedRecord.value = null
selectedRecordId.value = null
Object.keys(checkboxStates).forEach((id) => {
checkboxStates[id] = false
})
searchKeyword.value = ''
pagination.currentPage = 1
}
// 格式化金额显示
const formatAmount = (amount: number, currency: string) => {
return `${currency} ${amount?.toLocaleString() || 0}`
}
// 获取状态类型(暂未使用)
// const getStatusType = (status: string) => {
// const statusMap: Record<string, string> = {
// available: 'success',
// used: 'info',
// pending: 'warning',
// cancelled: 'danger'
// }
// return statusMap[status] || 'info'
// }
// 获取状态文本(暂未使用)
// const getStatusText = (status: string) => {
// const statusMap: Record<string, string> = {
// available: '可用',
// used: '已使用',
// pending: '待处理',
// cancelled: '已取消'
// }
// return statusMap[status] || status
// }
// 监听对话框显示状态
watch(visible, (newVal) => {
if (newVal) {
getPaymentRecords()
}
})
</script>
<style scoped lang="scss">
.payment-record-dialog {
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.clickable {
cursor: pointer;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
}
.dialog-footer {
display: flex;
justify-content: center;
gap: 12px;
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<template>
<div class="print-preview-container">
<div class="preview-header">
<div class="header-actions">
<el-button @click="download" v-if="showDownload">下载</el-button>
<el-button @click="print" type="primary">打印</el-button>
<el-button @click="close">关闭</el-button>
</div>
</div>
<div class="preview-content">
<div
v-if="pdf.images.length"
class="images-container"
>
<img
v-for="(img, idx) in pdf.images"
:key="idx"
:src="img"
class="preview-image"
/>
</div>
<iframe
v-else-if="pdf.blobUrl || pdf.url"
ref="frameRef"
:src="pdf.blobUrl || pdf.url"
class="preview-iframe"
@load="onIframeLoad"
@error="onIframeError"
/>
<div v-else class="no-content">
<p>没有可预览的内容</p>
<p>传入的 PDF URL: {{ pdfUrl }}</p>
<p>PDF Object URL: {{ pdf.url }}</p>
<p>PDF Blob URL: {{ pdf.blobUrl }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, watch } from 'vue'
import { usePdfPreview } from '@/composables/usePdfPreview'
export default defineComponent({
name: 'PrintPreview',
props: {
visible: {
type: Boolean,
default: false
},
showDownload: {
type: Boolean,
default: true
},
pdfUrl: {
type: String,
default: ''
}
},
emits: ['close'],
setup(props, { emit }) {
// 使用公共的 usePdfPreview
const { pdf, frameRef, openWithResponse, download, print, preparePreview, prepareImages } = usePdfPreview()
// 监听 visible 变化,当显示时加载 PDF
watch(() => props.visible, (newVisible) => {
console.log('PrintPreview visible 变化:', newVisible)
if (newVisible && props.pdfUrl) {
// 构造响应对象,模拟 API 返回格式
const response = {
url: props.pdfUrl,
file_url: props.pdfUrl,
file_url_full: props.pdfUrl
}
openWithResponse(response)
console.log('PrintPreview 设置 PDF URL:', props.pdfUrl)
}
})
// 监听 pdfUrl 变化
watch(() => props.pdfUrl, (newUrl) => {
console.log('PrintPreview pdfUrl 变化:', newUrl)
if (newUrl) {
// 构造响应对象,模拟 API 返回格式
const response = {
url: newUrl,
file_url: newUrl,
file_url_full: newUrl
}
openWithResponse(response)
console.log('PrintPreview 更新 PDF URL:', newUrl)
}
})
const close = () => {
emit('close')
}
// iframe 加载事件
const onIframeLoad = () => {
console.log('iframe 加载完成')
}
// iframe 错误事件
const onIframeError = (error: any) => {
console.error('iframe 加载错误:', error)
}
return {
frameRef,
pdf,
download,
print,
close,
onIframeLoad,
onIframeError
}
}
})
</script>
<style scoped lang="scss">
.print-preview-container {
display: flex;
flex-direction: column;
height: 100%;
max-height: 80vh;
}
.preview-header {
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
background: #fafafa;
border-radius: 6px 6px 0 0;
}
.header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.preview-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.images-container {
overflow: auto;
border: 1px solid #eee;
border-radius: 6px;
padding: 8px;
background: #fff;
}
.preview-image {
display: block;
width: 100%;
margin-bottom: 8px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.preview-iframe {
flex: 1;
width: 100%;
border: 1px solid #eee;
border-radius: 6px;
background: #fff;
}
.no-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
color: #666;
font-size: 14px;
p {
margin: 5px 0;
}
}
// 打印样式
@media print {
.print-preview-container {
max-height: none;
height: auto;
}
.preview-header {
display: none;
}
.preview-content {
height: auto;
}
.images-container {
border: none;
padding: 0;
}
.preview-image {
margin-bottom: 0;
box-shadow: none;
}
.preview-iframe {
border: none;
}
}
</style>
@@ -0,0 +1,175 @@
<template>
<div class="detail-wrap">
<div class="detail-grid">
<div class="item">
<span class="label">采购单号:</span>
<span class="value">{{ detailData.order_no || '—' }}</span>
</div>
<div class="item">
<span class="label">PI号:</span>
<span class="value">{{ detailData.pi_no || '—' }}</span>
</div>
<div class="item">
<span class="label">提交人:</span>
<span class="value">{{ detailData.buyer_name || '—' }}</span>
</div>
<div class="item full">
<span class="label">备注:</span>
<span class="value">{{ detailData.remark || '—' }}</span>
</div>
</div>
<div class="detail-block">
<div class="block-title">合同文件</div>
<div
v-if="detailData.contract_file_url && detailData.contract_file_url.length"
class="files-list"
>
<div class="file-row" v-for="(f, idx) in detailData.contract_file_url" :key="idx">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ f.name }}</span>
<a class="el-link el-link--primary" :href="f.url" target="_blank" rel="noopener">查看</a>
<a class="el-link el-link--info" :href="f.url" :download="f.name">下载</a>
</div>
</div>
<div v-else class="empty-tip">无合同文件</div>
</div>
<div class="detail-block">
<div class="block-title">备货单组</div>
<div v-if="detailData.stock_order && detailData.stock_order.length" class="groups">
<div class="group" v-for="(g, gi) in detailData.stock_order" :key="gi">
<div class="group-head">
<span class="order">{{ g.order_no || '备货单' }}</span>
<span class="seller" v-if="g.salesperson_snapshot"
>({{ g.salesperson_snapshot }})</span
>
<span class="date" v-if="g.biz_date">{{ g.biz_date }}</span>
</div>
<div class="chips">
<span class="chip" v-for="(v, vi) in g.stock_order_items || []" :key="vi">
{{ v.model_snapshot }}
</span>
</div>
</div>
</div>
<div v-else class="empty-tip">无备货单数据</div>
</div>
</div>
</template>
<script lang="ts">
import { Document } from '@element-plus/icons-vue'
import { PropType } from 'vue'
// 定义组件的数据类型
interface DetailData {
order_no?: string
pi_no?: string
buyer_name?: string
remark?: string
contract_file_url?: Array<{ name: string; url: string }>
stock_order?: Array<{
order_no?: string
salesperson_snapshot?: string
biz_date?: string
stock_order_items?: Array<{ model_snapshot: string }>
}>
}
// 使用 Vue 的 props 传递数据
export default {
name: 'ProcurementDetailView',
props: {
detailData: {
type: Object as PropType<DetailData>,
required: true
}
}
}
</script>
<style scoped lang="scss">
.detail-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px 16px;
}
.detail-grid .item {
display: flex;
align-items: center;
gap: 8px;
}
.detail-grid .item.full {
grid-column: 1 / -1;
}
.detail-grid .label {
color: var(--el-text-color-regular);
}
.detail-grid .value {
font-weight: 600;
color: var(--el-text-color-primary);
}
.detail-block {
border-top: 1px dashed var(--el-border-color-lighter);
padding-top: 8px;
}
.block-title {
font-weight: 600;
margin-bottom: 8px;
}
.files-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-row {
display: flex;
align-items: center;
gap: 10px;
}
.file-name {
flex: 1;
}
.empty-tip {
color: var(--el-text-color-secondary);
}
.groups {
display: flex;
flex-direction: column;
max-height: 38vh;
overflow-y: auto;
gap: 12px;
}
.group {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
padding: 14px 6px 8px;
background: var(--el-fill-color-blank);
}
.group-head {
display: flex;
gap: 8px;
margin-bottom: 10px;
align-content: center;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 4px 8px;
border-radius: 12px;
background: #f0f6ff;
color: #3a7af3;
border: 1px solid #e3edff;
font-size: 12px;
line-height: 18px;
}
</style>
@@ -0,0 +1,169 @@
<template>
<div class="detail-wrap">
<div class="detail-grid">
<div class="item">
<span class="label">采购单号:</span>
<span class="value">{{ detailData.order_no || '—' }}</span>
</div>
<div class="item">
<span class="label">PI号:</span>
<span class="value">{{ detailData.pi_no || '—' }}</span>
</div>
<div class="item">
<span class="label">提交人:</span>
<span class="value">{{ detailData.buyer_name || '—' }}</span>
</div>
<div class="item full">
<span class="label">备注:</span>
<span class="value">{{ detailData.remark || '—' }}</span>
</div>
</div>
<div class="detail-block">
<div class="block-title">合同文件</div>
<div
v-if="detailData.contract_file_url && detailData.contract_file_url.length"
class="files-list"
>
<div class="file-row" v-for="(f, idx) in detailData.contract_file_url" :key="idx">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ f.name }}</span>
<a class="el-link el-link--primary" :href="f.url" target="_blank" rel="noopener">查看</a>
<a class="el-link el-link--info" :href="f.url" :download="f.name">下载</a>
</div>
</div>
<div v-else class="empty-tip">无合同文件</div>
</div>
<div class="detail-block">
<div class="block-title">备货单组</div>
<div v-if="detailData.stock_order && detailData.stock_order.length" class="groups">
<div class="group" v-for="(g, gi) in detailData.stock_order" :key="gi">
<div class="group-head">
<span class="order">{{ g.order_no || '备货单' }}</span>
<span class="seller" v-if="g.salesperson_snapshot"
>({{ g.salesperson_snapshot }})</span
>
<span class="date" v-if="g.biz_date">{{ g.biz_date }}</span>
</div>
<div class="chips">
<span class="chip" v-for="(v, vi) in g.stock_order_items || []" :key="vi">
{{ v.model_snapshot }}
</span>
</div>
</div>
</div>
<div v-else class="empty-tip">无备货单数据</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Document } from '@element-plus/icons-vue'
import { PropType } from 'vue'
interface DetailData {
order_no?: string
pi_no?: string
buyer_name?: string
remark?: string
contract_file_url?: Array<{ name: string; url: string }>
stock_order?: Array<{
order_no?: string
salesperson_snapshot?: string
biz_date?: string
stock_order_items?: Array<{ model_snapshot: string }>
}>
}
defineProps({
detailData: {
type: Object as PropType<DetailData>,
required: true
}
})
</script>
<style scoped lang="scss">
.detail-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px 16px;
}
.detail-grid .item {
display: flex;
align-items: center;
gap: 8px;
}
.detail-grid .item.full {
grid-column: 1 / -1;
}
.detail-grid .label {
color: var(--el-text-color-regular);
}
.detail-grid .value {
font-weight: 600;
color: var(--el-text-color-primary);
}
.detail-block {
border-top: 1px dashed var(--el-border-color-lighter);
padding-top: 8px;
}
.block-title {
font-weight: 600;
margin-bottom: 8px;
}
.files-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-row {
display: flex;
align-items: center;
gap: 10px;
}
.file-name {
flex: 1;
}
.empty-tip {
color: var(--el-text-color-secondary);
}
.groups {
display: flex;
flex-direction: column;
max-height: 38vh;
overflow-y: auto;
gap: 12px;
}
.group {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
padding: 14px 6px 8px;
background: var(--el-fill-color-blank);
}
.group-head {
display: flex;
gap: 8px;
margin-bottom: 10px;
align-content: center;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 4px 8px;
border-radius: 12px;
background: #f0f6ff;
color: #3a7af3;
border: 1px solid #e3edff;
font-size: 12px;
line-height: 18px;
}
</style>
+164
View File
@@ -0,0 +1,164 @@
<template>
<div class="approval-timeline">
<div v-for="(rec, idx) in records" :key="idx" class="timeline-item">
<div class="left">
<div class="avatar-box">
<span class="initial">{{ rec.updated_by?.slice(0, 1) || '—' }}</span>
</div>
</div>
<div class="right">
<div class="row name-role">
<span class="name">编辑人:{{ rec.updated_by }}</span>
</div>
<div class="row time">{{ rec.updated_at }}</div>
<div class="card-box">
<el-card>
<h4>汇款人:{{ rec.remitter }}</h4>
<p
><span>付款来源:{{ rec.payment_apply_id }}</span>
<span>账户:{{ rec.account_short_desc }}</span>
</p>
<p
><span>金额:{{ rec.amount }} </span><span>币种:{{ rec.currency_name }}</span></p
>
<p
><span>折让金额:{{ rec.discount_amount }}</span
><span>实际汇率:{{ rec.exchange_rate }}</span>
</p>
<p>总金额(人民币)实际汇率:{{ rec.total_amount_cny }}</p>
<p>收款时间:{{ rec.received_at }}</p>
</el-card>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface ApprovalRecordItem {
account_id?: string
account_name?: string
amount?: string
arrived_at?: string
country_id?: string
country_name?: string
created_at?: string
created_by?: string
currency_id?: string
currency_name?: string
discount_amount?: string
exchange_rate?: string
fx_receipt_file_full?: string
account_short_desc?: string
id?: string
is_domestic_return?: string
payment_apply_id?: string
receipt_id?: string
receipt_no?: string
received_at?: string
remitter?: string
total_amount_cny?: string
updated_at?: string
updated_by?: string
voucher_no?: string
}
defineProps<{ records: ApprovalRecordItem[] }>()
</script>
<style scoped lang="scss">
/* 审批记录 时间线 */
.approval-timeline {
padding: 8px 4px 8px 0;
max-height: 600px;
overflow-y: scroll;
}
.timeline-item {
display: flex;
align-items: flex-start;
gap: 10px;
position: relative;
}
.timeline-item + .timeline-item {
margin-top: 50px;
}
.left {
width: 46px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.avatar-box {
width: 46px;
height: 46px;
background: #3d7bff;
border-radius: 10px;
color: #fff;
position: relative;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 20px;
}
.status-dot {
position: absolute;
right: -4px;
bottom: -4px;
font-size: 9px;
padding: 3px 1px;
border-radius: 50%;
background: #22c55e;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.status-dot.pending {
background: #3471ff;
padding: 3px 3px;
}
.status-dot.rejected {
background: #ff0000;
padding: 3px 3px;
}
/* 连接下一个节点的虚线 */
.timeline-item:not(:last-child) .left::after {
content: '';
position: absolute;
top: 52px; /* avatar 46px + 间距 6px */
left: 23px; /* 盒子宽度一半,确保竖线居中 */
width: 0;
height: calc(100% + 116px); /* 加上相邻项之间的间距 */
border-left: 2px dotted #bfbfbf;
}
.right {
flex: 1;
padding-top: 2px;
}
.row.name-role {
color: #222;
margin-bottom: 10px;
}
.name {
font-weight: 600;
}
.role {
color: #666;
margin-left: 4px;
}
.time {
color: #888;
font-size: 12px;
margin-top: 2px;
margin-top: 10px;
}
.empty {
color: #999;
padding: 8px 0 4px 4px;
}
.card-box p{
display: flex;
justify-content: space-between;
}
</style>
@@ -0,0 +1,213 @@
<template>
<el-drawer :with-header="false" :model-value="modelValue" :size="isMobile ? '100%' : '480px'" @close="$emit('update:modelValue', false)">
<div class="chat-container">
<div class="header">
<div class="header-left">
<span class="name">{{ title }}</span>
</div>
<div class="header-right">
<el-icon class="icon-close" :size="20" @click="$emit('update:modelValue', false)">
<Close />
</el-icon>
</div>
</div>
<div class="chat-messages" ref="messageContainer">
<template v-for="(message, index) in messages" :key="index">
<div :class="['message-item', String(message.user_id) === String(currentUserId) ? 'message-right' : 'message-left']">
<el-avatar :size="32" :src="message.user_avatar" class="message-avatar" />
<div class="message-content">
<div class="message-info">
<span class="sender-name">{{ message.user_name }}</span>
<span class="message-time">{{ message.updated_at }}</span>
</div>
<div class="message-text" v-html="message.content"></div>
<div class="message-attachments" v-if="message.attachment && message.attachment.length > 0">
<div class="attachment-item" v-for="(attachment, attIndex) in message.attachment" :key="attIndex">
<template v-if="/\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(attachment.original_name)">
<img :src="attachment.storage_path_full" :alt="attachment.original_name" class="attachment-thumb" @click="handleChatImageClick(attachment.storage_path_full)" />
</template>
<template v-else-if="/\.(mp4|webm|ogg)$/i.test(attachment.original_name)">
<video :src="attachment.storage_path_full" controls class="attachment-thumb" @click.prevent="handleChatVideoClick(attachment.storage_path_full)"></video>
</template>
<template v-else>
<div class="file-attachment">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ attachment.original_name }}</span>
<el-button type="primary" size="small" link @click="$emit('download', attachment)">下载</el-button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
<div class="chat-input" v-if="canAdd">
<div class="chat-attachment-upload">
<el-upload v-model:file-list="chatFileList" class="chat-upload" :action="actionImageUplode" :on-success="handleChatUploadSuccess" :headers="uploadHeaders" multiple :limit="5" :on-exceed="handleChatExceed" :show-file-list="false">
<el-button type="primary" link>
<el-icon><Paperclip /></el-icon>
添加附件
</el-button>
</el-upload>
<div class="uploaded-attachments" v-if="chatFileList.length > 0">
<div class="attachment-preview-item" v-for="(file, idx) in chatFileList" :key="idx">
<template v-if="/\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.name)">
<img :src="file.file_url || file.response?.data?.file_url" :alt="file.name" class="preview-thumb" />
</template>
<template v-else>
<el-icon class="file-icon"><Document /></el-icon>
</template>
<span class="file-name">{{ file.name }}</span>
<el-button type="danger" size="small" link @click="removeChatFile(idx)">
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
<el-input v-model="messageText" type="textarea" :rows="3" placeholder="输入消息" resize="none" />
<div class="chat-input-actions">
<el-button type="primary" @click="emitSend">发送</el-button>
</div>
</div>
</div>
<!-- 内置预览 -->
<ElImageViewer v-if="imageViewerVisible" :url-list="chatImageList" :initial-index="imageViewerIndex" @close="closeImageViewer" />
<ElDialog v-model="videoDialogVisible" width="60%" top="10vh" :show-close="true" append-to-body>
<video v-if="currentVideoUrl" :src="currentVideoUrl" controls autoplay style="width: 100%; max-height: 70vh; display: block"></video>
</ElDialog>
</el-drawer>
</template>
<script setup lang="ts">
import { ElImageViewer } from 'element-plus'
import { Paperclip, Document, Close } from '@element-plus/icons-vue'
const props = defineProps<{
modelValue: boolean
title?: string
messages: any[]
currentUserId: string | number
canAdd?: boolean
actionImageUplode?: string
uploadHeaders?: Record<string, any>
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'send', payload: { text: string; attachments: any[] }): void
(e: 'download', attachment: any): void
}>()
const { width } = useWindowSize()
const isMobile = computed(() => width.value < 500)
const messageText = ref('')
const chatFileList = ref<any[]>([])
const messageContainer = ref<HTMLElement | null>(null)
watch(
() => props.messages,
() => {
nextTick(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
})
},
{ immediate: true, deep: true }
)
const handleChatUploadSuccess = (response: any, uploadFile: any, uploadFiles: any[]) => {
chatFileList.value = uploadFiles
.filter((item) => item.status === 'success')
.map((item) => {
const data = item.response?.data
if (data) {
return {
name: data.original_name,
file_url: data.storage_path_full,
status: 'success',
response: { data }
}
}
return null
})
.filter(Boolean) as any[]
}
const handleChatExceed = () => {
// no-op; parent can handle toast
}
const removeChatFile = (index: number) => {
chatFileList.value.splice(index, 1)
}
const emitSend = () => {
const text = messageText.value.trim()
if (!text && chatFileList.value.length === 0) return
const attachments = chatFileList.value.map((file) => {
const data = file.response?.data
if (data) {
return {
original_name: data.original_name,
storage_path: data.storage_path || data.file_name
}
}
return null
}).filter(Boolean) as any[]
emit('send', { text, attachments })
messageText.value = ''
chatFileList.value = []
}
// 预览
const chatImageList = ref<string[]>([])
const imageViewerVisible = ref(false)
const imageViewerIndex = ref(0)
function handleChatImageClick(url: string) {
chatImageList.value = [url]
imageViewerIndex.value = 0
imageViewerVisible.value = true
}
const currentVideoUrl = ref('')
const videoDialogVisible = ref(false)
function handleChatVideoClick(url: string) {
currentVideoUrl.value = url
videoDialogVisible.value = true
}
function closeImageViewer() {
imageViewerVisible.value = false
chatImageList.value = []
}
</script>
<style scoped lang="scss">
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
.header-left .name { font-size: 16px; font-weight: 500; }
.header-right .icon-close { cursor: pointer; }
}
.chat-container { display: flex; flex-direction: column; height: calc(100% - 70px); }
.chat-messages { flex: 1; padding: 30px 16px; overflow-y: auto; border-top: 1px solid var(--el-border-color-lighter); }
.message-item { display: flex; gap: 8px; align-items: flex-start; width: 100%; margin-bottom: 30px; }
.message-item.message-left { justify-content: flex-start; }
.message-item.message-right { flex-direction: row-reverse; }
.message-content { display: flex; flex-direction: column; max-width: 70%; }
.message-info { display: flex; gap: 8px; margin-bottom: 4px; font-size: 12px; }
.message-text { font-size: 14px; color: var(--art-gray-900); border-radius: 6px; padding: 10px 14px; line-height: 1.4; white-space: pre-line; }
.message-left .message-text { background-color: #f8f5ff; }
.message-right .message-text { background-color: #e9f3ff; }
.message-attachments { margin-top: 8px; }
.message-attachments .attachment-thumb { max-width: 200px; max-height: 150px; border-radius: 6px; cursor: pointer; border: 1px solid var(--el-border-color-lighter); }
.chat-input { padding: 16px 16px 0; }
.chat-input-actions { display: flex; justify-content: right; margin-top: 12px; }
.chat-attachment-upload { margin-bottom: 12px; }
.uploaded-attachments { display: flex; flex-wrap: wrap; gap: 8px; }
.attachment-preview-item { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--el-fill-color-light); border-radius: 6px; border: 1px solid var(--el-border-color-lighter); max-width: 200px; }
.preview-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
</style>
+457
View File
@@ -0,0 +1,457 @@
<template>
<el-dialog
:model-value="visibleLocal"
:title="currentFile?.name || '文件预览'"
:close-on-click-modal="false"
:destroy-on-close="false"
width="90%"
:fullscreen="isFullscreen"
@close="handleClose"
>
<div class="file-preview-toolbar">
<div class="left">
<!-- <el-button-group>
<el-button size="small" @click="prevFile" :disabled="!hasPrev">上一页</el-button>
<el-button size="small" @click="nextFile" :disabled="!hasNext">下一页</el-button>
</el-button-group>
<el-divider direction="vertical" />
<el-button size="small" @click="zoomOut">-</el-button>
<el-button size="small" @click="resetZoom">重置</el-button>
<el-button size="small" @click="zoomIn">+</el-button>
<el-divider direction="vertical" /> -->
<!-- <el-button size="small" @click="rotateLeft">⟲</el-button> -->
<!-- <el-button size="small" @click="rotateRight">⟳</el-button> -->
<!-- <el-divider direction="vertical" />
<el-button size="small" @click="toggleFullscreen">{{
isFullscreen ? '退出全屏' : '全屏'
}}</el-button> -->
</div>
<div class="right">
<!-- <el-button size="small" type="primary" :href="currentFile?.url" target="_blank"
>在新标签打开</el-button
> -->
<!-- <el-button size="small" @click="downloadCurrent">下载</el-button> -->
<el-button size="small" type="danger" @click="handleClose">关闭</el-button>
</div>
</div>
<div class="file-preview-body">
<template v-if="!currentFile">
<div class="no-file">无要预览的文件</div>
</template>
<template v-else>
<!-- IMAGE -->
<div v-if="fileType === 'image'" class="viewer-wrap" :key="`image-${currentFile.url}`">
<div class="viewer" :style="viewerStyle">
<img
:src="currentFile.url"
:key="currentFile.url"
@error="onError"
@load="onLoadImage"
/>
</div>
</div>
<!-- PDF -->
<div v-else-if="fileType === 'pdf'" class="viewer-wrap" :key="`pdf-${currentFile.url}`">
<div class="viewer" :style="viewerStyle">
<!-- 使用 object / iframe 作为预览,支持大多数现代浏览器 -->
<object
:key="`pdf-obj-${currentFile.url}`"
:data="appendPdfParams(currentFile.url)"
type="application/pdf"
width="100%"
height="100%"
>
<iframe
:key="`pdf-iframe-${currentFile.url}`"
:src="appendPdfParams(currentFile.url)"
width="100%"
height="100%"
></iframe>
<div class="fallback"
>无法在浏览器内预览 PDF。<a :href="currentFile.url" target="_blank"
>点击打开</a
></div
>
</object>
</div>
</div>
<!-- OFFICE (Word, Excel, PPT) 使用微软 Office 在线查看器,仅当文件对外可访问时有效 -->
<div
v-else-if="fileType === 'office'"
class="viewer-wrap"
:key="`office-${currentFile.url}`"
>
<div class="viewer" :style="viewerStyle">
<template v-if="isHttpUrl(currentFile.url)">
<iframe
:key="`office-iframe-${currentFile.url}`"
:src="officeViewerUrl(currentFile.url)"
width="100%"
height="100%"
frameborder="0"
></iframe>
</template>
<template v-else>
<div class="fallback"
>Office 文件必须是可通过公网访问的 URL 才能使用在线预览。<a
:href="currentFile.url"
target="_blank"
>点击打开或下载</a
></div
>
</template>
</div>
</div>
<!-- 文本 / 代码 -->
<div v-else-if="fileType === 'text'" class="viewer-wrap" :key="`text-${currentFile.url}`">
<div class="viewer text-viewer" :style="viewerStyle">
<pre v-if="textContent !== null">{{ textContent }}</pre>
<div v-else class="loading">正在加载文本内容...</div>
<div v-if="textError" class="error">加载文本失败:{{ textError }}</div>
</div>
</div>
<!-- 未知 -->
<div v-else class="viewer-wrap unknown-viewer" :key="`unknown-${currentFile.url}`">
<div class="viewer" :style="viewerStyle">
<div class="unknown">
<p>无法在线预览此类型文件。</p>
<p><a :href="currentFile.url" target="_blank">点击打开或下载文件</a></p>
</div>
</div>
</div>
</template>
</div>
<template #footer>
<div class="footer-info">{{ currentFile?.name || '' }}</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup name="FilesPreviews">
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
// --- Props ---
interface FileItem {
file_name: any
name?: string
url: string
mime?: string // 可选,优先使用
}
const props = defineProps<{
modelValue: boolean
file?: FileItem | null // 单文件
files?: FileItem[] | null // 多文件
startIndex?: number
enableFullscreen?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'close'): void
}>()
// --- Local state ---
const visibleLocal = ref<boolean>(!!props.modelValue)
const currentIndex = ref<number>(props.startIndex ?? 0)
const isFullscreen = ref<boolean>(!!props.enableFullscreen)
const zoom = ref<number>(1)
const rotate = ref<number>(0) // deg
const textContent = ref<string | null>(null)
const textError = ref<string | null>(null)
// --- Computed ---
const currentFile = computed((): FileItem | null => {
if (props.files && props.files.length > 0) {
const idx = Math.min(Math.max(0, currentIndex.value), props.files.length - 1)
return props.files[idx]
}
return props.file ?? null
})
function resetViewState(clearText = true) {
zoom.value = 1
rotate.value = 0
if (clearText) {
textContent.value = null
textError.value = null
}
}
// --- Watchers ---
watch(
() => props.modelValue,
(v) => (visibleLocal.value = v)
)
watch(visibleLocal, (v) => emit('update:modelValue', v))
watch(
() => props.file,
() => {
currentIndex.value = 0
resetViewState()
loadTextIfNeeded()
}
)
watch(
() => props.files,
() => {
currentIndex.value = props.startIndex ?? 0
resetViewState()
loadTextIfNeeded()
}
)
watch(currentIndex, () => {
resetViewState(false)
loadTextIfNeeded()
})
// 监听当前文件变化,确保文件切换时正确更新
watch(
() => currentFile.value?.url,
(newUrl, oldUrl) => {
if (newUrl !== oldUrl && newUrl) {
resetViewState()
loadTextIfNeeded()
}
}
)
const hasNext = computed(
() => !!(props.files && currentIndex.value < (props.files?.length ?? 0) - 1)
)
const hasPrev = computed(() => !!(props.files && currentIndex.value > 0))
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function prevFile() {
if (hasPrev.value) currentIndex.value -= 1
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function nextFile() {
if (hasNext.value) currentIndex.value += 1
}
function handleClose() {
visibleLocal.value = false
emit('close')
}
// --- Viewer helpers ---
function getExtension(name = '') {
const m = name.split('.')
return (m.length > 1 ? m[m.length - 1].toLowerCase() : '').trim()
}
function mimeFromName(name = '') {
const ext = getExtension(name)
const map: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
bmp: 'image/bmp',
webp: 'image/webp',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
}
return map[ext] ?? ''
}
const fileType = computed(() => {
const f = currentFile.value
if (!f) return 'unknown'
let names = f.name
if (names == '') {
names = f.file_name
}
const mime = (f.mime || mimeFromName(names) || '').toLowerCase()
const ext = getExtension(names)
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext))
return 'image'
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf'
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) return 'office'
if (mime.startsWith('text/') || ['txt', 'md', 'csv', 'log', 'json', 'xml'].includes(ext))
return 'text'
return 'unknown'
})
function isHttpUrl(u?: string) {
return !!u && /^https?:\/\//i.test(u)
}
function officeViewerUrl(url: string) {
// 使用微软 Office 在线查看器
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
}
function appendPdfParams(url: string) {
// 某些浏览器可以在 pdf 后加参数调整行为,例如 #toolbar=0
return url
}
// --- transform style for viewer ---
const viewerStyle = computed(() => {
return {
transform: `scale(${zoom.value}) rotate(${rotate.value}deg)`,
transformOrigin: 'center top'
} as Record<string, string>
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function zoomIn() {
zoom.value = Math.min(zoom.value + 0.25, 4)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function zoomOut() {
zoom.value = Math.max(zoom.value - 0.25, 0.25)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function resetZoom() {
zoom.value = 1
rotate.value = 0
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function rotateLeft() {
rotate.value = (rotate.value - 90) % 360
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function rotateRight() {
rotate.value = (rotate.value + 90) % 360
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
// --- text loading ---
async function loadTextIfNeeded() {
if (!currentFile.value) return
if (fileType.value !== 'text') return
textContent.value = null
textError.value = null
try {
const resp = await fetch(currentFile.value.url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const txt = await resp.text()
textContent.value = txt
} catch (err: any) {
textError.value = String(err?.message ?? err)
}
}
// --- image handlers ---
function onError() {
ElMessage.warning('图片加载失败')
}
function onLoadImage() {
// 可以在这里获取自然尺寸进行布局优化
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function downloadCurrent() {
if (!currentFile.value) return
// 打开下载链接(浏览器通常会下载)
const a = document.createElement('a')
a.href = currentFile.value.url
a.download = currentFile.value.name || ''
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
// expose some controls externally if needed
onMounted(() => {
if (props.modelValue) visibleLocal.value = true
if (fileType.value === 'text') loadTextIfNeeded()
})
// 导出组件
defineExpose({})
</script>
<style lang="scss" scoped>
.file-preview-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.file-preview-body {
height: calc(80vh - 80px);
display: flex;
align-items: center;
justify-content: center;
}
.viewer-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
}
.viewer {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.viewer img {
max-width: 100%;
max-height: 100%;
display: block;
}
.text-viewer pre {
white-space: pre-wrap;
word-wrap: break-word;
text-align: left;
padding: 12px;
height: 100%;
overflow: auto;
}
.unknown-viewer .unknown {
text-align: center;
}
.no-file {
color: #999;
}
.footer-info {
color: #666;
}
.loading {
color: #999;
}
.error {
color: #f56c6c;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
import { computed } from 'vue'
import { useMenuBadgeStore } from '@/store/modules/menuBadge'
import { useMenuStore } from '@/store/modules/menu'
import mittBus from '@/utils/sys/mittBus'
// 全局初始化状态
let globalInitialized = false
// 重置全局初始化状态(用于退出登录后重新初始化)
export const resetGlobalInitialized = () => {
globalInitialized = false
// console.log('徽标系统全局初始化状态已重置')
}
/**
* 菜单徽标管理 Composable
* 提供菜单徽标的统一管理接口
*/
export function useMenuBadge() {
const menuBadgeStore = useMenuBadgeStore()
const menuStore = useMenuStore()
// 计算属性:获取菜单列表(带徽标状态)
const menuListWithBadges = computed(() => {
const menuList = menuStore.menuList
if (!menuList.length) return menuList
// 同步徽标状态到菜单列表
menuBadgeStore.syncBadgeWithMenuList(menuList)
return menuList
})
// 更新菜单徽标
const updateMenuBadge = (path: string, count: number, type: 'dot' | 'text' = 'text') => {
menuBadgeStore.updateBadgeData(path, count, type)
}
// 批量更新菜单徽标
const batchUpdateMenuBadges = (
updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>
) => {
menuBadgeStore.batchUpdateBadgeData(updates)
}
// 清除菜单徽标
const clearMenuBadge = (path: string) => {
menuBadgeStore.clearBadge(path)
}
// 清除所有菜单徽标
const clearAllMenuBadges = () => {
menuBadgeStore.clearAllBadges()
}
// 获取菜单徽标数量
const getMenuBadgeCount = (path: string) => {
return menuBadgeStore.getBadgeCount(path)
}
// 检查菜单是否显示徽标
const isMenuBadgeVisible = (path: string) => {
return menuBadgeStore.shouldShowBadge(path)
}
// 获取菜单文本徽标
const getMenuTextBadge = (path: string) => {
return menuBadgeStore.shouldShowTextBadge(path)
}
// 处理WebSocket徽标更新消息
const handleWebSocketBadgeUpdate = (data: any) => {
// 防止重复处理
if (data._badgeHandled) {
return
}
data._badgeHandled = true
// 首先检查是否是数组格式的徽标数据
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
handleMenuBadgeDataArray(data)
return
}
try {
// 根据消息类型处理不同的徽标更新
if (data.event === 'menu.badge.update') {
const { path, count, type } = data.data
if (path && typeof count === 'number') {
updateMenuBadge(path, count, type || 'text')
}
} else if (data.event === 'menu.badge.batch_update') {
const { updates } = data.data
if (Array.isArray(updates)) {
batchUpdateMenuBadges(updates)
}
} else if (data.event === 'menu.badge.clear') {
const { path } = data.data
if (path) {
clearMenuBadge(path)
} else {
clearAllMenuBadges()
}
}
} catch (error) {
console.error('处理WebSocket徽标更新消息时出错:', error, data)
}
}
// 处理菜单徽标数据数组格式
const handleMenuBadgeDataArray = (data: any) => {
// 防止重复处理
if (data._arrayHandled) {
return
}
data._arrayHandled = true
// 支持多种数据格式:
// 1. { event: '...', data: [{ url: '...', badge: 3 }] }
// 2. [{ url: '...', badge: 3 }] (直接是数组)
let badgeArray: any[] = []
if (Array.isArray(data)) {
// 如果 data 本身就是数组
badgeArray = data
} else if (Array.isArray(data.data)) {
// 如果 data.data 是数组
badgeArray = data.data
} else {
console.warn('未找到数组格式的徽标数据:', data)
return
}
if (badgeArray.length > 0) {
const updates = badgeArray
.filter((item: any) => {
const isValid = item.url && typeof item.badge === 'number'
if (!isValid) {
console.warn('跳过无效的徽标数据项:', item)
}
return isValid
})
.map((item: any) => ({
path: item.url,
count: item.badge,
type: 'text' as const
}))
if (updates.length > 0) {
batchUpdateMenuBadges(updates)
} else {
console.warn('没有有效的徽标更新项')
}
} else {
console.warn('徽标数据数组为空')
}
}
// 处理通知相关的徽标更新
const handleNotificationBadgeUpdate = (data: any) => {
// console.log('收到通知徽标更新消息:', data)
// 根据通知类型更新对应菜单的徽标
if (data.event === 'notify.receive') {
// 通知接收,更新通知菜单徽标
const currentCount = getMenuBadgeCount('/notification')
updateMenuBadge('/notification', currentCount + 1, 'text')
} else if (data.event === 'notify.read') {
// 通知已读,减少通知菜单徽标
const currentCount = getMenuBadgeCount('/notification')
updateMenuBadge('/notification', Math.max(0, currentCount - 1), 'text')
} else if (data.event === 'notify.clear') {
// 清除所有通知,清除通知菜单徽标
clearMenuBadge('/notification')
}
}
// 处理审批相关的徽标更新
const handleApprovalBadgeUpdate = (data: any) => {
// 首先检查是否是数组格式的徽标数据
if (Array.isArray(data) || Array.isArray(data.data)) {
handleMenuBadgeDataArray(data)
return
}
if (data.event === 'approval.new') {
// 新审批,更新审批菜单徽标
const currentCount = getMenuBadgeCount('/admin/approval')
updateMenuBadge('/admin/approval', currentCount + 1, 'text')
} else if (data.event === 'approval.processed') {
// 审批已处理,减少审批菜单徽标
const currentCount = getMenuBadgeCount('/admin/approval')
updateMenuBadge('/admin/approval', Math.max(0, currentCount - 1), 'text')
}
}
// 处理库存相关的徽标更新
const handleStockBadgeUpdate = (data: any) => {
// console.log('收到库存徽标更新消息:', data)
if (data.event === 'stock.low') {
// 库存不足,更新库存菜单徽标
const currentCount = getMenuBadgeCount('/admin/stock')
updateMenuBadge('/admin/stock', currentCount + 1, 'dot')
} else if (data.event === 'stock.normal') {
// 库存正常,减少库存菜单徽标
const currentCount = getMenuBadgeCount('/admin/stock')
updateMenuBadge('/admin/stock', Math.max(0, currentCount - 1), 'dot')
}
}
// 统一的WebSocket消息处理
const handleWebSocketMessage = (data: any) => {
if (!data) return
// 如果已经被特定事件处理过(如 menu-badge-update),则跳过
// 注意:_badgeProcessed 标记是在 wsClient.ts 中设置的
if (data._badgeProcessed) {
return
}
// 首先检查是否是数组格式的徽标数据(无论是否有 event 字段)
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
handleMenuBadgeDataArray(data)
return
}
// 如果没有 event 字段,无法继续处理
if (!data.event) return
// 处理菜单徽标数据数组格式(如 approval.todo.counts)
if (data.event.includes('.todo.counts') || data.event.includes('.badge.counts')) {
handleMenuBadgeDataArray(data)
} else if (data.event.includes('menu.badge')) {
handleWebSocketBadgeUpdate(data)
} else if (data.event.includes('notify')) {
handleNotificationBadgeUpdate(data)
} else if (data.event.includes('approval')) {
handleApprovalBadgeUpdate(data)
} else if (data.event.includes('stock')) {
handleStockBadgeUpdate(data)
}
}
// 初始化徽标系统
const initBadgeSystem = () => {
if (globalInitialized) {
return
}
globalInitialized = true
// 标记页面已加载完成
menuBadgeStore.setPageReady(true)
// 先清理可能存在的旧监听器,避免重复注册
mittBus.off('ws:message', handleWebSocketMessage)
mittBus.off('menu-badge-update', handleWebSocketBadgeUpdate)
mittBus.off('approval-badge-update', handleApprovalBadgeUpdate)
mittBus.off('stock-badge-update', handleStockBadgeUpdate)
mittBus.off('notification-badge-update', handleNotificationBadgeUpdate)
mittBus.off('menu:updated')
// 监听WebSocket消息
mittBus.on('ws:message', handleWebSocketMessage)
// 监听特定的徽标更新事件
mittBus.on('menu-badge-update', handleWebSocketBadgeUpdate)
mittBus.on('approval-badge-update', handleApprovalBadgeUpdate)
mittBus.on('stock-badge-update', handleStockBadgeUpdate)
mittBus.on('notification-badge-update', handleNotificationBadgeUpdate)
// 监听菜单数据变化,同步徽标状态
mittBus.on('menu:updated', () => {
menuBadgeStore.syncBadgeWithMenuList(menuStore.menuList)
})
}
// 清理徽标系统
const cleanupBadgeSystem = () => {
if (!globalInitialized) {
// console.log('菜单徽标系统未全局初始化,跳过清理')
return
}
// console.log('清理菜单徽标系统')
globalInitialized = false
// 移除事件监听
mittBus.off('ws:message', handleWebSocketMessage)
mittBus.off('menu-badge-update', handleWebSocketBadgeUpdate)
mittBus.off('approval-badge-update', handleApprovalBadgeUpdate)
mittBus.off('stock-badge-update', handleStockBadgeUpdate)
mittBus.off('notification-badge-update', handleNotificationBadgeUpdate)
mittBus.off('menu:updated')
// 重置页面加载状态
menuBadgeStore.setPageReady(false)
}
// 注意:不在组件中自动初始化,避免重复初始化
// 初始化应该在App.vue中全局调用一次
return {
// 计算属性
menuListWithBadges,
// 方法
updateMenuBadge,
batchUpdateMenuBadges,
clearMenuBadge,
clearAllMenuBadges,
getMenuBadgeCount,
isMenuBadgeVisible,
getMenuTextBadge,
// 系统方法
initBadgeSystem,
cleanupBadgeSystem,
handleWebSocketMessage
}
}
+78
View File
@@ -0,0 +1,78 @@
import { ref, reactive } from 'vue'
export interface OrderItem {
model?: string
quantity?: number
exteriorColor?: string
interiorColor?: string
modification?: string
unitPrice?: number
totalPrice?: number
officialPrice?: number
discount?: string
}
export interface PaymentItem {
country?: string
date?: string
currency?: string
account?: string
remitter?: string
amount?: number
}
export interface OrderData {
contract_file_url: { name: string; url: string }[]
orderNo?: string
salesperson?: string
piNo?: string
date?: string
clearanceCountry?: string
customerSource?: string
customerName?: string
customerCountry?: string
items?: OrderItem[]
contractRate?: string
totalCif?: number
declarant?: string
etaShipDate?: string
shippingMethod?: string
domesticPort?: string
tradeTerms?: string
latestArrival?: string
payments?: PaymentItem[]
}
export function useOrderDetailDialog() {
const visible = ref(false)
const title = ref('订单详情')
const orderData = ref<OrderData>({
contract_file_url: [],
items: [],
payments: []
})
const show = (data: OrderData, dialogTitle?: string) => {
orderData.value = data
title.value = dialogTitle || '订单详情'
visible.value = true
}
const hide = () => {
visible.value = false
}
const print = () => {
// 可以在这里添加打印逻辑
window.print()
}
return {
visible,
title,
orderData,
show,
hide,
print
}
}
+140
View File
@@ -0,0 +1,140 @@
import { ref, reactive } from 'vue'
export const usePdfPreview = () => {
const state = reactive({
visible: false,
url: '',
name: '',
blobUrl: '',
images: [] as string[]
})
const frameRef = ref<HTMLIFrameElement | null>(null)
// 动态加载 pdf.js(优先本地依赖,失败回退 CDN)
const loadPdfJs = async (): Promise<any> => {
// 统一使用 CDN,避免本地依赖缺失导致打包时报错
const ensure = (src: string) =>
new Promise<void>((resolve, reject) => {
const s = document.createElement('script')
s.src = src
s.onload = () => resolve()
s.onerror = () => reject(new Error('load script failed'))
document.head.appendChild(s)
})
await ensure('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js')
await ensure('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js')
return (window as any).pdfjsLib
}
const open = (url: string, name = '') => {
if (!url) return
state.url = url
state.name = name
state.visible = true
state.images = []
}
const openWithResponse = (res: any) => {
const url = res?.data?.file_url_full || res?.file_url_full
const name = res?.data?.file_url || res?.file_url || ''
open(url, name)
}
const inferFilenameFromUrl = (url: string): string => {
try {
const u = new URL(url)
const parts = u.pathname.split('/')
return parts[parts.length - 1] || 'document.pdf'
} catch (e) {
const parts = (url || '').split('?')[0].split('/')
return parts[parts.length - 1] || 'document.pdf'
}
}
const download = async () => {
if (!state.url) return
const filename = state.name || inferFilenameFromUrl(state.url)
try {
const resp = await fetch(state.url, { credentials: 'omit' })
const blob = await resp.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch {
window.open(state.url, '_blank')
}
}
// 预览时也用 blob,提升兼容性
const preparePreview = async () => {
if (!state.url) return
try {
const resp = await fetch(state.url, { credentials: 'omit' })
const blob = await resp.blob()
state.blobUrl && window.URL.revokeObjectURL(state.blobUrl)
state.blobUrl = window.URL.createObjectURL(blob)
} catch {
state.blobUrl = ''
}
}
// 生成图片以便无需浏览器 PDF 插件即可展示
const prepareImages = async () => {
if (!state.url) return
state.images = []
try {
const resp = await fetch(state.url, { credentials: 'omit' })
const arrayBuffer = await resp.arrayBuffer()
const pdfjsLib = await loadPdfJs()
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
const images: string[] = []
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p)
const viewport = page.getViewport({ scale: 1.5 })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) continue
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvasContext: context, viewport }).promise
images.push(canvas.toDataURL('image/png'))
}
state.images = images
} catch {
state.images = []
}
}
const print = () => {
const frame = frameRef.value
if (frame && frame.contentWindow) {
frame.contentWindow.focus()
frame.contentWindow.print()
return
}
if (state.url) {
const win = window.open(state.url, '_blank')
if (win) {
win.addEventListener('load', () => win.print())
}
}
}
return {
pdf: state,
frameRef,
open,
openWithResponse,
download,
print,
preparePreview,
prepareImages
}
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -4,11 +4,11 @@ import { useUserStore } from '@/store/modules/user'
export function useUploadHeaders() {
const userStore = useUserStore()
const uploadHeaders = computed(() => ({
'Fdesk-Language': userStore.language,
'Fdesk-Corp-Abbr': userStore.companyabbr,
'Fdesk-System-Code': 'fdesk',
'Fdesk-Login-Type': userStore.loginType,
'Fdesk-Language': userStore.language || 'zh',
'Fdesk-Corp-Abbr': userStore.companyabbr || 'default',
'Fdesk-System-Code': userStore.systemsabbr || 'fdesk',
'Fdesk-Login-Type': userStore.loginType || 'upwd',
'Fdesk-Auth-Token': 'Bearer ' + userStore.accessToken
}))
return { uploadHeaders }
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ const appConfig: SystemConfig = {
] as const,
// 系统其他项默认配置
systemSetting: {
defaultMenuWidth: 240, // 菜单宽度
defaultMenuWidth: 200, // 菜单宽度
defaultCustomRadius: '0.75', // 自定义圆角
defaultTabStyle: 'tab-default' // 标签样式
}
+57 -23
View File
@@ -59,27 +59,65 @@ async function handleRouteGuard(
// 设置系统主题
setSystemTheme(to)
// 处理登录状态
if (!(await handleLoginStatus(to, userStore, next))) {
return
// 特殊处理电子签名页面 - 优先处理,避免登录检查
if (to.path === '/auth/signature/mobile') {
// 设置认证信息
if (to.query.accessToken || to.query.token) {
const token = (to.query.accessToken as string) || (to.query.token as string)
if (token) {
userStore.setToken(token)
userStore.setLoginStatus(true)
}
}
// 设置其他必要的参数到userStore
if (to.query.companyid && to.query.companyabbr) {
userStore.setCompany(to.query.companyid as string, to.query.companyabbr as string)
}
if (to.query.systemsid && to.query.systemsabbr) {
userStore.setSystems(to.query.systemsid as string, to.query.systemsabbr as string)
}
if (to.query.loginType) {
userStore.setLoginType(to.query.loginType as 'feishu' | 'upwd')
}
if (to.matched.length > 0) {
setWorktab(to)
setPageTitle(to)
next()
return
} else {
// 如果路由没有匹配,直接跳转到404
next(RoutesAlias.Exception404)
return
}
}
// 处理动态路由注册
if (!isRouteRegistered.value && userStore.isLogin) {
// 处理其他页面的登录状态(跳过电子签名页面)
if (to.path !== '/auth/signature/mobile') {
if (!(await handleLoginStatus(to, userStore, next))) {
return
}
}
// 处理动态路由注册(跳过电子签名页面)
if (to.path !== '/auth/signature/mobile' && !isRouteRegistered.value && userStore.isLogin) {
await handleDynamicRoutes(to, router, next)
return
}
// 处理已知的匹配路由
if (to.matched.length > 0) {
// 处理已知的匹配路由(跳过电子签名页面)
if (to.path !== '/auth/signature/mobile' && to.matched.length > 0) {
setWorktab(to)
setPageTitle(to)
next()
return
}
// 尝试刷新路由重新注册
if (userStore.isLogin) {
// 尝试刷新路由重新注册(跳过电子签名页面)
if (to.path !== '/auth/signature/mobile' && userStore.isLogin) {
isRouteRegistered.value = false
await handleDynamicRoutes(to, router, next)
return
@@ -97,6 +135,7 @@ async function handleLoginStatus(
userStore: ReturnType<typeof useUserStore>,
next: NavigationGuardNext
): Promise<boolean> {
// 检查其他页面的登录状态
if (!userStore.isLogin && to.path !== RoutesAlias.Login && !to.meta.noLogin) {
userStore.logOut()
next({
@@ -105,6 +144,7 @@ async function handleLoginStatus(
})
return false
}
return true
}
@@ -200,22 +240,17 @@ async function registerAndStoreMenu(
*/
function handleMenuError(error: unknown): void {
console.error('菜单处理失败:', error)
// 清理所有状态
const userStore = useUserStore()
const menuStore = useMenuStore()
const worktabStore = useWorktabStore()
// 清空菜单和工作台
menuStore.clearMenuList()
worktabStore.clearAll()
// 重置路由注册状态
isRouteRegistered.value = false
// 登出用户
userStore.logOut()
throw error instanceof Error ? error : new Error('获取菜单列表失败,请重新登录')
}
@@ -251,20 +286,21 @@ function isValidMenuList(menuList: AppRouteRecord[]): boolean {
*/
export function resetRouterState(router: Router): void {
isRouteRegistered.value = false
// 清理所有动态注册的路由(不仅仅是带有dynamic标记的)
const routesToRemove: string[] = []
router.getRoutes().forEach((route) => {
// 排除静态路由和登录页面
if (route.name &&
route.name !== 'Login' &&
route.name !== 'Exception404' &&
route.name !== 'Exception500' &&
!route.path.startsWith('/exception/')) {
if (
route.name &&
route.name !== 'Login' &&
route.name !== 'Exception404' &&
route.name !== 'Exception500' &&
!route.path.startsWith('/exception/')
) {
routesToRemove.push(route.name as string)
}
})
// 移除动态路由
routesToRemove.forEach((routeName) => {
try {
@@ -273,11 +309,9 @@ export function resetRouterState(router: Router): void {
console.warn(`移除路由失败: ${routeName}`, error)
}
})
// 清空菜单数据
const menuStore = useMenuStore()
menuStore.clearMenuList()
// 清空工作台标签页
const worktabStore = useWorktabStore()
worktabStore.clearAll()
+363
View File
@@ -0,0 +1,363 @@
import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router'
import { WEB_LINKS } from '@/utils/constants'
/**
* 菜单列表、异步路由
*
* 支持两种模式:
* 1. 前端静态配置 - 直接使用本文件中定义的路由配置
* 2. 后端动态配置 - 后端返回菜单数据,前端解析生成路由
*
* 菜单标题(title):
* 可以是 i18n 的 key,也可以是字符串,比如:'用户列表'
*/
export const asyncRoutes: AppRouteRecord[] = [
{
name: 'Dashboard',
path: '/dashboard',
component: RoutesAlias.Home,
meta: {
title: 'menus.dashboard.title',
icon: '&#xe721;'
},
children: [
{
path: 'console',
name: 'Console',
component: RoutesAlias.Dashboard,
meta: {
title: 'menus.dashboard.console',
keepAlive: false,
fixedTab: true
}
},
{
path: 'analysis',
name: 'Analysis',
component: RoutesAlias.Analysis,
meta: {
title: 'menus.dashboard.analysis',
keepAlive: false
}
},
{
path: 'ecommerce',
name: 'Ecommerce',
component: RoutesAlias.Ecommerce,
meta: {
title: 'menus.dashboard.ecommerce',
keepAlive: false
}
}
]
},
{
path: '/work',
name: 'Work',
component: RoutesAlias.Home,
// component:'/work',
meta: {
title: 'menus.work.title',
icon: '&#xe88b;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'order',
name: '工单列表',
component: RoutesAlias.Order,
meta: {
title: 'menus.work.order',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'type',
name: 'Type',
component: RoutesAlias.OrderType,
meta: {
title: 'menus.work.type',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
}
]
},
{
path: '/system',
name: 'System',
component: RoutesAlias.Home,
meta: {
title: 'menus.system.title',
icon: '&#xe7b9;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'User',
component: RoutesAlias.User,
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'Role',
component: RoutesAlias.Role,
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'user-center',
name: 'UserCenter',
component: RoutesAlias.UserCenter,
meta: {
title: 'menus.system.userCenter',
isHide: true,
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: RoutesAlias.Menu,
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{
title: '新增',
auth_mark: 'add'
},
{
title: '编辑',
auth_mark: 'edit'
},
{
title: '删除',
auth_mark: 'delete'
}
]
}
},
{
path: 'nested',
name: 'Nested',
component: '',
meta: {
title: 'menus.system.nested',
keepAlive: true
},
children: [
{
path: 'menu1',
name: 'NestedMenu1',
component: RoutesAlias.NestedMenu1,
meta: {
title: 'menus.system.menu1',
icon: '&#xe676;',
keepAlive: true
}
},
{
path: 'menu2',
name: 'NestedMenu2',
component: '',
meta: {
title: 'menus.system.menu2',
icon: '&#xe676;',
keepAlive: true
},
children: [
{
path: 'menu2-1',
name: 'NestedMenu2-1',
component: RoutesAlias.NestedMenu21,
meta: {
title: 'menus.system.menu21',
icon: '&#xe676;',
keepAlive: true
}
}
]
},
{
path: 'menu3',
name: 'NestedMenu3',
component: '',
meta: {
title: 'menus.system.menu3',
icon: '&#xe676;',
keepAlive: true
},
children: [
{
path: 'menu3-1',
name: 'NestedMenu3-1',
component: RoutesAlias.NestedMenu31,
meta: {
title: 'menus.system.menu31',
icon: '&#xe676;',
keepAlive: true
}
},
{
path: 'menu3-2',
name: 'NestedMenu3-2',
component: '',
meta: {
title: 'menus.system.menu32',
icon: '&#xe676;',
keepAlive: true
},
children: [
{
path: 'menu3-2-1',
name: 'NestedMenu3-2-1',
component: RoutesAlias.NestedMenu321,
meta: {
title: 'menus.system.menu321',
icon: '&#xe676;',
keepAlive: true
}
}
]
}
]
}
]
}
]
},
{
path: '/car',
name: 'Car',
component: RoutesAlias.Home,
meta: {
title: 'menus.car.title',
icon: '&#xe7fd;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'usedcar',
name: 'Usedcar',
component: RoutesAlias.Usedcar,
meta: {
title: 'menus.car.used',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'newcar',
name: 'Newcar',
component: RoutesAlias.Newcar,
meta: {
title: 'menus.car.newcar',
keepAlive: true,
roles: ['R_SUPER']
}
}
]
},
{
path: '/article',
name: 'Article',
component: RoutesAlias.Home,
meta: {
title: 'menus.article.title',
icon: '&#xe7ae;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'article-list',
name: 'ArticleList',
component: RoutesAlias.ArticleList,
meta: {
title: 'menus.article.articleList',
keepAlive: true,
authList: [
{
title: '新增',
auth_mark: 'add'
},
{
title: '编辑',
auth_mark: 'edit'
}
]
}
},
{
path: 'detail',
name: 'ArticleDetail',
component: RoutesAlias.ArticleDetail,
meta: {
title: 'menus.article.articleDetail',
isHide: true,
keepAlive: true,
activePath: '/article/article-list' // 激活菜单路径
}
},
{
path: 'comment',
name: 'ArticleComment',
component: RoutesAlias.Comment,
meta: {
title: 'menus.article.comment',
keepAlive: true
}
},
{
path: 'publish',
name: 'ArticlePublish',
component: RoutesAlias.ArticlePublish,
meta: {
title: 'menus.article.articlePublish',
keepAlive: true,
authList: [
{
title: '发布',
auth_mark: 'article/article-publish/add'
}
]
}
}
]
},
{
path: '/safeguard',
name: 'Safeguard',
component: RoutesAlias.Home,
meta: {
title: 'menus.safeguard.title',
icon: '&#xe816;',
keepAlive: false
},
children: [
{
path: 'server',
name: 'SafeguardServer',
component: RoutesAlias.Server,
meta: {
title: 'menus.safeguard.server',
keepAlive: true
}
}
]
}
]
+163 -331
View File
@@ -1,7 +1,5 @@
import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router'
import { WEB_LINKS } from '@/utils/constants'
/**
* 菜单列表、异步路由
*
@@ -14,350 +12,184 @@ import { WEB_LINKS } from '@/utils/constants'
*/
export const asyncRoutes: AppRouteRecord[] = [
{
name: 'Dashboard',
path: '/dashboard',
component: RoutesAlias.Home,
path: '/dashboard/console/finance',
name: 'Consolefinance',
component: RoutesAlias.consolefinance,
meta: {
title: 'menus.dashboard.title',
icon: '&#xe721;'
},
children: [
{
path: 'console',
name: 'Console',
component: RoutesAlias.Dashboard,
meta: {
title: 'menus.dashboard.console',
keepAlive: false,
fixedTab: true
}
},
{
path: 'analysis',
name: 'Analysis',
component: RoutesAlias.Analysis,
meta: {
title: 'menus.dashboard.analysis',
keepAlive: false
}
},
{
path: 'ecommerce',
name: 'Ecommerce',
component: RoutesAlias.Ecommerce,
meta: {
title: 'menus.dashboard.ecommerce',
keepAlive: false
}
}
]
title: '仪表盘'
}
},
{
path: '/work',
name: 'Work',
component: RoutesAlias.Home,
// component:'/work',
path: '/dashboard/console/backoffice',
name: 'Backoffice',
component: RoutesAlias.backoffice,
meta: {
title: 'menus.work.title',
icon: '&#xe88b;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'order',
name: '工单列表',
component: RoutesAlias.Order,
meta: {
title: 'menus.work.order',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'type',
name: 'Type',
component: RoutesAlias.OrderType,
meta: {
title: 'menus.work.type',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
}
]
title: '仪表盘'
}
},
{
path: '/system',
name: 'System',
component: RoutesAlias.Home,
path: '/admin/inquiry_system',
name: 'Inquiry_system',
component: RoutesAlias.inquirySystem,
meta: {
title: 'menus.system.title',
icon: '&#xe7b9;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'User',
component: RoutesAlias.User,
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'Role',
component: RoutesAlias.Role,
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'user-center',
name: 'UserCenter',
component: RoutesAlias.UserCenter,
meta: {
title: 'menus.system.userCenter',
isHide: true,
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: RoutesAlias.Menu,
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{
title: '新增',
auth_mark: 'add'
},
{
title: '编辑',
auth_mark: 'edit'
},
{
title: '删除',
auth_mark: 'delete'
}
]
}
},
{
path: 'nested',
name: 'Nested',
component: '',
meta: {
title: 'menus.system.nested',
keepAlive: true
},
children: [
{
path: 'menu1',
name: 'NestedMenu1',
component: RoutesAlias.NestedMenu1,
meta: {
title: 'menus.system.menu1',
icon: '&#xe676;',
keepAlive: true
}
},
{
path: 'menu2',
name: 'NestedMenu2',
component: '',
meta: {
title: 'menus.system.menu2',
icon: '&#xe676;',
keepAlive: true
},
children: [
{
path: 'menu2-1',
name: 'NestedMenu2-1',
component: RoutesAlias.NestedMenu21,
meta: {
title: 'menus.system.menu21',
icon: '&#xe676;',
keepAlive: true
}
}
]
},
{
path: 'menu3',
name: 'NestedMenu3',
component: '',
meta: {
title: 'menus.system.menu3',
icon: '&#xe676;',
keepAlive: true
},
children: [
{
path: 'menu3-1',
name: 'NestedMenu3-1',
component: RoutesAlias.NestedMenu31,
meta: {
title: 'menus.system.menu31',
icon: '&#xe676;',
keepAlive: true
}
},
{
path: 'menu3-2',
name: 'NestedMenu3-2',
component: '',
meta: {
title: 'menus.system.menu32',
icon: '&#xe676;',
keepAlive: true
},
children: [
{
path: 'menu3-2-1',
name: 'NestedMenu3-2-1',
component: RoutesAlias.NestedMenu321,
meta: {
title: 'menus.system.menu321',
icon: '&#xe676;',
keepAlive: true
}
}
]
}
]
}
]
}
]
title: '询单系统'
}
},
{
path: '/car',
name: 'Car',
component: RoutesAlias.Home,
path: '/crmpc/customer/add',
name: 'CrmpcustomerAdd',
component: RoutesAlias.CrmpcustomerAdd,
meta: {
title: 'menus.car.title',
icon: '&#xe7fd;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'usedcar',
name: 'Usedcar',
component: RoutesAlias.Usedcar,
meta: {
title: 'menus.car.used',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'newcar',
name: 'Newcar',
component: RoutesAlias.Newcar,
meta: {
title: 'menus.car.newcar',
keepAlive: true,
roles: ['R_SUPER']
}
}
]
title: '新增客户资料',
isHide: true
// isHideTab: true
}
},
{
path: '/article',
name: 'Article',
component: RoutesAlias.Home,
path: '/crmpc/customer/show',
name: 'CrmpcustomerShow',
component: RoutesAlias.CrmpcustomerShow,
meta: {
title: 'menus.article.title',
icon: '&#xe7ae;',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'article-list',
name: 'ArticleList',
component: RoutesAlias.ArticleList,
meta: {
title: 'menus.article.articleList',
keepAlive: true,
authList: [
{
title: '新增',
auth_mark: 'add'
},
{
title: '编辑',
auth_mark: 'edit'
}
]
}
},
{
path: 'detail',
name: 'ArticleDetail',
component: RoutesAlias.ArticleDetail,
meta: {
title: 'menus.article.articleDetail',
isHide: true,
keepAlive: true,
activePath: '/article/article-list' // 激活菜单路径
}
},
{
path: 'comment',
name: 'ArticleComment',
component: RoutesAlias.Comment,
meta: {
title: 'menus.article.comment',
keepAlive: true
}
},
{
path: 'publish',
name: 'ArticlePublish',
component: RoutesAlias.ArticlePublish,
meta: {
title: 'menus.article.articlePublish',
keepAlive: true,
authList: [
{
title: '发布',
auth_mark: 'article/article-publish/add'
}
]
}
}
]
title: '客户资料',
isHide: true
// isHideTab: true
}
},
{
path: '/safeguard',
name: 'Safeguard',
component: RoutesAlias.Home,
path: '/crmpc/customer/edit',
name: 'CrmpcustomerEdit',
component: RoutesAlias.CrmpcustomerEdit,
meta: {
title: 'menus.safeguard.title',
icon: '&#xe816;',
keepAlive: false
},
children: [
{
path: 'server',
name: 'SafeguardServer',
component: RoutesAlias.Server,
meta: {
title: 'menus.safeguard.server',
keepAlive: true
}
}
]
title: '编辑客户资料',
isHide: true
// isHideTab: true
}
},
{
path: '/crmpc/customer/followadd',
name: 'CustomerFollowAdd',
component: RoutesAlias.CrmpcustomerFollowadd,
meta: {
title: '添加跟进',
isHide: true
// isHideTab: true
}
},
{
path: '/crmpc/recycle',
name: 'Recycle',
component: RoutesAlias.recycle,
meta: {
title: '回收站',
isHide: true,
keepAlive: true
}
},
// 备货单相关路由
{
path: '/crmpc/stocking/stocking_list',
name: 'StockingList',
component: 'crmpc/stocking/stocking_list',
meta: {
title: '备货单列表',
isHide: true
}
},
{
path: '/crmpc/stocking/Add',
name: 'StockingAdd',
component: 'crmpc/stocking/Add',
meta: {
title: '创建备货单',
isHide: true,
isHideTab: false,
keepAlive: true
}
},
{
path: '/crmpc/stocking/show',
name: 'StockingShow',
component: 'crmpc/stocking/show',
meta: {
title: '备货单详情',
isHide: true
}
},
{
path: '/crmpc/stocking/final_add',
name: 'StockingFinalAdd',
component: 'crmpc/stocking/final_add',
meta: {
title: '最终备货单',
isHide: true,
isHideTab: false,
keepAlive: true
}
},
{
path: '/crmpc/stocking/final_show',
name: 'StockingFinalShow',
component: 'crmpc/stocking/final_show',
meta: {
title: '最终备货单详情',
isHide: true
}
},
//审批详情
{
path: '/crmpc/stocking/stocking_auth_detil',
name: 'StockingAuth',
component: 'crmpc/stocking/stocking_auth_detil',
meta: {
title: '最终备货单详情',
isHide: true
}
},
// 审批流程添加
{
path: '/admin/approval/approval_add',
name: 'approvalAdd',
component: 'admin/approval/approval_add',
meta: {
title: '审批流程添加',
isHide: true
}
},
{
path: '/crmpc/approval/auth_detil',
name: 'approvalDetil',
component: 'crmpc/approval/auth_detil',
meta: {
title: '审核详情',
isHide: true
}
},
// 管理端备货单相关路由
{
path: '/admin/stock/stocking_list_admin',
name: 'AdminStockingList',
component: 'admin/stock/stocking_list_admin',
meta: {
title: '备货单列表',
isHide: true
}
},
{
path: '/admin/stock/show_admin',
name: 'AdminStockingShow',
component: 'admin/stock/show_admin',
meta: {
title: '备货单详情',
isHide: true
}
},
{
path: '/admin/stock/final_show_admin',
name: 'AdminStockingFinalShow',
component: 'admin/stock/final_show_admin',
meta: {
title: '最终备货单详情',
isHide: true
}
}
]
+7 -1
View File
@@ -9,7 +9,7 @@ import Home from '@views/index/index.vue'
export const staticRoutes: AppRouteRecordRaw[] = [
{
path: '/',
redirect: HOME_PAGE
redirect: localStorage.getItem('home_page') || HOME_PAGE
},
{
path: '/console',
@@ -41,6 +41,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@/views/auth/feishu-callback.vue'),
meta: { title: '飞书回调', noLogin: true, isHideTab: true }
},
{
path: '/auth/signature/mobile',
name: 'SignatureMobile',
component: () => import('@views/auth/signature/mobile.vue'),
meta: { title: '电子签名', isHideTab: true, noLogin: true, setTheme: true }
},
{
path: '/exception',
component: Home,
+21 -2
View File
@@ -42,7 +42,6 @@ export enum RoutesAlias {
Role = '/system/role', // 角色
Usedcar = '/car/usedcar', // 角色
Newcar = '/car/newcar', // 角色
UserCenter = '/system/user-center', // 用户中心
Menu = '/system/menu', // 菜单
NestedMenu1 = '/system/nested/menu1', // 嵌套菜单1
NestedMenu21 = '/system/nested/menu2', // 嵌套菜单2-1
@@ -51,7 +50,27 @@ export enum RoutesAlias {
Server = '/safeguard/server', // 服务器
ChangeLog = '/change/log', // 更新日志
Order = '/work/order', // 工单
OrderType = '/work/type' // 工单
OrderType = '/work/type', // 工单
Crmpchome = '/crmpc/home', // crm首页
Crmphighseas = '/crmpc/highseas', // crm公海
Crmpcustomer = '/crmpc/customer', // crm客户
CrmpcustomerAdd = '/crmpc/customer/Add', //客户添加
CrmpcustomerEdit = '/crmpc/customer/Edit', //客户编辑
CrmpcustomerShow = '/crmpc/customer/Show', //客户详情
CrmpcustomerFollowadd = '/crmpc/customer/Followadd', //添加跟进
recycle = '/crmpc/recycle', //回收站
UserCenter = '/system/user-center', // 用户中心
inquirySystem = '/admin/inquiry_system', // 询单系统
customer = '/admin/customer', // 管理端客户列表
customeraudit = '/admin/customer/customeraudit', // 管理端待审客户列表
// 备货单相关路由
StockingList = '/crmpc/stocking/stocking_list', // 备货单列表
StockingAdd = '/crmpc/stocking/Add', // 创建备货单
StockingShow = '/crmpc/stocking/show', // 备货单详情
StockingFinalAdd = '/crmpc/stocking/final_add', // 最终备货单添加
StockingFinalShow = '/crmpc/stocking/final_show', // 最终备货单详情
consolefinance = '/dashboard/console/finance', //财务仪表盘
backoffice = '/dashboard/console/backoffice' // 内勤仪表盘
}
// 主页路由
+11 -5
View File
@@ -130,15 +130,21 @@ function loadComponent(componentPath: string, routeName: string): () => Promise<
}
// 构建可能的路径
const fullPath = `../../views${componentPath}.vue`
const fullPathWithIndex = `../../views${componentPath}/index.vue`
const fullPathNoSlash = `../../views${componentPath}.vue`
const fullPathWithIndexNoSlash = `../../views${componentPath}/index.vue`
const fullPathWithSlash = `../../views/${componentPath}.vue`
const fullPathWithIndexSlash = `../../views/${componentPath}/index.vue`
// 先尝试直接路径,再尝试添加/index的路径
const module = modules[fullPath] || modules[fullPathWithIndex]
// 依次尝试四种可能路径(兼容是否带斜杠的情况)
const module =
modules[fullPathNoSlash] ||
modules[fullPathWithIndexNoSlash] ||
modules[fullPathWithSlash] ||
modules[fullPathWithIndexSlash]
if (!module) {
console.error(
`[路由错误] 未找到组件:${routeName},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}`
`[路由错误] 未找到组件:${routeName},尝试过的路径: ${fullPathNoSlash}、${fullPathWithIndexNoSlash}、${fullPathWithSlash} 和 ${fullPathWithIndexSlash}`
)
return () =>
Promise.resolve({
+295
View File
@@ -0,0 +1,295 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { AppRouteRecord } from '@/types/router'
import { useUserStore } from './user'
// 菜单徽标数据类型
export interface MenuBadgeData {
/** 菜单路径 */
path: string
/** 徽标数量 */
count: number
/** 徽标类型:dot(红点) | text(数字) */
type: 'dot' | 'text'
/** 最后更新时间 */
lastUpdated: number
}
// 菜单徽标Store
export const useMenuBadgeStore = defineStore('menuBadgeStore', () => {
// 徽标数据映射 - 使用普通对象而不是Map,避免持久化问题
const badgeData = ref<Record<string, MenuBadgeData>>({})
// 页面是否已加载完成
const isPageReady = ref(false)
// 待处理的徽标更新队列(页面未加载完成时使用)
const pendingUpdates = ref<Array<{ path: string; count: number; type: 'dot' | 'text' }>>([])
// 获取当前用户ID
const getCurrentUserId = (): string => {
try {
const userStore = useUserStore()
return userStore.info?.userId?.toString() || 'anonymous'
} catch {
return 'anonymous'
}
}
// 获取用户特定的存储key
const getUserStorageKey = (): string => {
const userId = getCurrentUserId()
return `menu-badge-${userId}`
}
// 从localStorage加载用户特定的徽标数据
const loadUserBadgeData = () => {
try {
const storageKey = getUserStorageKey()
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
badgeData.value = parsed.badgeData || {}
// 注意:不恢复 isPageReady,因为页面状态应该在运行时由代码控制
// isPageReady.value 应该在 setPageReady() 中设置,而不是从存储中恢复
pendingUpdates.value = parsed.pendingUpdates || []
}
} catch (error) {
console.warn('加载用户徽标数据失败:', error)
}
}
// 保存用户特定的徽标数据到localStorage
const saveUserBadgeData = () => {
try {
const storageKey = getUserStorageKey()
const data = {
badgeData: badgeData.value,
// 注意:不保存 isPageReady,因为页面状态应该在运行时由代码控制
// 页面每次加载时都应该重新设置为 true
pendingUpdates: pendingUpdates.value
}
localStorage.setItem(storageKey, JSON.stringify(data))
} catch (error) {
console.warn('保存用户徽标数据失败:', error)
}
}
// 清理其他用户的徽标数据
const cleanupOtherUserData = () => {
try {
const currentUserId = getCurrentUserId()
const keys = Object.keys(localStorage)
keys.forEach((key) => {
if (key.startsWith('menu-badge-') && !key.endsWith(`-${currentUserId}`)) {
localStorage.removeItem(key)
}
})
} catch (error) {
console.warn('清理其他用户数据失败:', error)
}
}
// 计算属性:获取所有徽标数据
const allBadgeData = computed(() => {
return { ...badgeData.value }
})
// 获取指定菜单的徽标数据
const getBadgeData = (path: string): MenuBadgeData | null => {
return badgeData.value[path] || null
}
// 获取指定菜单的徽标数量
const getBadgeCount = (path: string): number => {
const data = badgeData.value[path]
return data?.count || 0
}
// 检查菜单是否应该显示徽标
const shouldShowBadge = (path: string): boolean => {
const data = badgeData.value[path]
return Boolean(data && data.count > 0)
}
// 检查菜单是否应该显示文本徽标
const shouldShowTextBadge = (path: string): string | null => {
const data = badgeData.value[path]
if (data && data.count > 0 && data.type === 'text') {
return data.count > 99 ? '99+' : data.count.toString()
}
return null
}
// 设置页面加载状态
const setPageReady = (ready: boolean) => {
isPageReady.value = ready
// 页面加载完成时,加载用户特定的徽标数据
if (ready) {
loadUserBadgeData()
// 确保 isPageReady 保持为 true(防止被其他逻辑覆盖)
isPageReady.value = true
// 处理待处理的更新
if (pendingUpdates.value.length > 0) {
pendingUpdates.value.forEach((update) => {
updateBadgeData(update.path, update.count, update.type)
})
pendingUpdates.value = []
// 再次确认页面状态
isPageReady.value = true
}
}
}
// 更新徽标数据
const updateBadgeData = (path: string, count: number, type: 'dot' | 'text' = 'text') => {
// 如果页面未加载完成,将更新加入队列
if (!isPageReady.value) {
// console.log('页面未加载完成,将徽标更新加入队列:', { path, count, type })
// 检查是否已存在相同路径的待处理更新,避免重复
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === path)
if (existingIndex >= 0) {
pendingUpdates.value[existingIndex] = { path, count, type }
} else {
pendingUpdates.value.push({ path, count, type })
}
console.log('检查菜单是否应该显示徽标:', path, badgeData.value)
// 保存到用户特定存储
saveUserBadgeData()
return
}
if (count <= 0) {
// 如果数量为0或负数,移除徽标
delete badgeData.value[path]
} else {
// 更新或创建徽标数据
badgeData.value[path] = {
path,
count,
type,
lastUpdated: Date.now()
}
}
// 保存到用户特定存储
saveUserBadgeData()
}
// 批量更新徽标数据
const batchUpdateBadgeData = (
updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>
) => {
if (!isPageReady.value) {
updates.forEach((update) => {
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === update.path)
if (existingIndex >= 0) {
pendingUpdates.value[existingIndex] = {
path: update.path,
count: update.count,
type: update.type || 'text'
}
} else {
pendingUpdates.value.push({
path: update.path,
count: update.count,
type: update.type || 'text'
})
}
})
// 保存到用户特定存储
saveUserBadgeData()
return
}
updates.forEach((update) => {
updateBadgeData(update.path, update.count, update.type || 'text')
})
// 批量更新后保存
saveUserBadgeData()
}
// 清除指定菜单的徽标
const clearBadge = (path: string) => {
updateBadgeData(path, 0)
}
// 清除所有徽标
const clearAllBadges = () => {
badgeData.value = {}
pendingUpdates.value = []
// 保存到用户特定存储
saveUserBadgeData()
}
// 从菜单列表同步徽标状态
const syncBadgeWithMenuList = (menuList: AppRouteRecord[]) => {
if (!isPageReady.value) {
// console.log('页面未加载完成,跳过菜单徽标同步')
return
}
const updateMenuBadge = (menus: AppRouteRecord[]) => {
menus.forEach((menu) => {
const badgeData = getBadgeData(menu.path)
if (badgeData) {
// 更新菜单的徽标状态
menu.meta.showBadge = badgeData.type === 'dot' && badgeData.count > 0
menu.meta.showTextBadge =
badgeData.type === 'text' && badgeData.count > 0
? badgeData.count > 99
? '99+'
: badgeData.count.toString()
: undefined
} else {
// 清除菜单的徽标状态
menu.meta.showBadge = false
menu.meta.showTextBadge = undefined
}
// 递归处理子菜单
if (menu.children) {
updateMenuBadge(menu.children)
}
})
}
updateMenuBadge(menuList)
}
// 获取待处理更新的数量
const getPendingUpdatesCount = () => pendingUpdates.value.length
// 获取待处理的更新列表
const getPendingUpdates = () => [...pendingUpdates.value]
return {
// 状态
badgeData,
isPageReady,
pendingUpdates,
// 计算属性
allBadgeData,
// 方法
getBadgeData,
getBadgeCount,
shouldShowBadge,
shouldShowTextBadge,
setPageReady,
updateBadgeData,
batchUpdateBadgeData,
clearBadge,
clearAllBadges,
syncBadgeWithMenuList,
getPendingUpdatesCount,
getPendingUpdates,
// 用户隔离相关方法
loadUserBadgeData,
saveUserBadgeData,
cleanupOtherUserData,
getCurrentUserId
}
})
+212
View File
@@ -0,0 +1,212 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { LanguageEnum } from '@/enums/appEnum'
import { router } from '@/router'
import { useSettingStore } from './setting'
import { useWorktabStore } from './worktab'
import { AppRouteRecord } from '@/types/router'
import { setPageTitle } from '@/router/utils/utils'
import { resetRouterState } from '@/router/guards/beforeEach'
import { RoutesAlias } from '@/router/routesAlias'
import { UserService } from '@/api/usersApi' // 新增导入
let refreshTimeout: ReturnType<typeof setTimeout> | null = null // 定时器变量
// 用户
export const useUserStore = defineStore(
'userStore',
() => {
const language = ref(LanguageEnum.ZH)
const isLogin = ref(false)
const isLock = ref(false)
const lockPassword = ref('')
const info = ref<Partial<Api.User.UserInfo>>({})
const searchHistory = ref<AppRouteRecord[]>([])
const accessToken = ref('')
const refreshToken = ref('')
const companyid = ref('')
const companyabbr = ref('')
const systemsid = ref('')
const systemsabbr = ref('')
const home_page = ref('')
const loginType = ref<'feishu' | 'upwd'>('upwd')
const tokenExpireAt = ref<number>(0)
const getUserInfo = computed(() => info.value)
const getSettingState = computed(() => useSettingStore().$state)
const getWorktabState = computed(() => useWorktabStore().$state)
const setUserInfo = (newInfo: Api.User.UserInfo) => {
info.value = newInfo
}
const setLoginStatus = (status: boolean) => {
isLogin.value = status
}
const setLanguage = (lang: LanguageEnum) => {
setPageTitle(router.currentRoute.value)
language.value = lang
}
const setLoginType = (type: 'feishu' | 'upwd') => {
loginType.value = type
}
const setSearchHistory = (list: AppRouteRecord[]) => {
searchHistory.value = list
}
const setLockStatus = (status: boolean) => {
isLock.value = status
}
const setLockPassword = (password: string) => {
lockPassword.value = password
}
const setToken = (newAccessToken: string, newRefreshToken?: string, expiresIn?: number) => {
accessToken.value = newAccessToken
if (newRefreshToken) {
refreshToken.value = newRefreshToken
}
if (expiresIn) {
const expireAt = Date.now() + expiresIn * 1000
tokenExpireAt.value = expireAt
localStorage.setItem('token_expire_at', String(expireAt))
// 检查是否需要重新设置定时器
const currentLeftSeconds = Math.floor((expireAt - Date.now()) / 1000)
if (currentLeftSeconds > 0) {
scheduleTokenRefresh(currentLeftSeconds)
}
}
}
const setCompany = (id: string, abbr: string) => {
companyid.value = id
companyabbr.value = abbr
}
const setSystems = (id: string, abbr: string) => {
systemsid.value = id
systemsabbr.value = abbr
}
// 设置首页
const sethome_page = (page: string) => {
home_page.value = page
}
function restoreTokenTimer() {
const expireAt = Number(localStorage.getItem('token_expire_at') || '0')
if (accessToken.value && expireAt > Date.now()) {
const leftSeconds = Math.floor((expireAt - Date.now()) / 1000)
scheduleTokenRefresh(leftSeconds)
}
}
let isScheduled = false
function scheduleTokenRefresh(expiresIn: number) {
if (refreshTimeout) {
clearTimeout(refreshTimeout)
refreshTimeout = null
}
// 提前1分钟刷新
const refreshTime = (expiresIn - 60) * 1000
if (refreshTime > 0) {
if (!isScheduled) {
isScheduled = true
refreshTimeout = setTimeout(() => {
refreshTokenAction().finally(() => {
isScheduled = false // 重置标志位
})
}, refreshTime)
} else {
console.log('scheduleTokenRefresh is already scheduled, skipping...')
}
} else {
console.error('Invalid refreshTime:', refreshTime)
}
}
async function refreshTokenAction() {
try {
const { data } = await UserService.refreshToken({ refresh_token: refreshToken.value })
// 动态计算剩余时间
const currentTime = Date.now()
const expireAt = currentTime + data.expires_in * 1000
const leftSeconds = Math.floor((expireAt - currentTime) / 1000)
setToken(data.access_token, data.refresh_token, leftSeconds)
} catch (e) {
console.error('Token refresh failed:', e)
logOut()
}
}
const logOut = () => {
// 清空所有本地缓存
localStorage.clear()
sessionStorage.clear()
systemsabbr.value = ''
systemsid.value = ''
companyabbr.value = ''
companyid.value = ''
home_page.value = ''
info.value = {}
isLogin.value = false
isLock.value = false
lockPassword.value = ''
accessToken.value = ''
refreshToken.value = ''
if (refreshTimeout) clearTimeout(refreshTimeout) // 清理定时器
// 清理工作台标签页
useWorktabStore().clearAll()
// 清理sessionStorage中的用户信息缓存
sessionStorage.removeItem('lastUserInfo')
sessionStorage.removeItem('iframeRoutes')
// 重置路由状态
resetRouterState(router)
// 跳转到登录页
router.push(RoutesAlias.Login)
}
return {
language,
isLogin,
isLock,
lockPassword,
info,
companyid,
companyabbr,
systemsid,
systemsabbr,
home_page,
searchHistory,
loginType,
accessToken,
refreshToken,
tokenExpireAt,
getUserInfo,
getSettingState,
getWorktabState,
setUserInfo,
setLoginStatus,
setLanguage,
setSearchHistory,
setLockStatus,
setLockPassword,
setToken,
setCompany,
setSystems,
setLoginType,
sethome_page,
logOut,
restoreTokenTimer
}
},
{
persist: {
key: 'user',
storage: localStorage,
afterRestore: (ctx: { store: { restoreTokenTimer: () => void } }) => {
ctx.store.restoreTokenTimer()
}
} as any
}
)
+73 -16
View File
@@ -25,6 +25,9 @@ export const useUserStore = defineStore(
const refreshToken = ref('')
const companyid = ref('')
const companyabbr = ref('')
const systemsid = ref('')
const systemsabbr = ref('')
const home_page = ref('')
const loginType = ref<'feishu' | 'upwd'>('upwd')
const tokenExpireAt = ref<number>(0)
@@ -65,17 +68,28 @@ export const useUserStore = defineStore(
refreshToken.value = newRefreshToken
}
if (expiresIn) {
// 记录绝对过期时间
const expireAt = Date.now() + expiresIn * 1000
tokenExpireAt.value = expireAt
localStorage.setItem('token_expire_at', String(expireAt))
scheduleTokenRefresh(expiresIn)
// 检查是否需要重新设置定时器
const currentLeftSeconds = Math.floor((expireAt - Date.now()) / 1000)
if (currentLeftSeconds > 0) {
scheduleTokenRefresh(currentLeftSeconds)
}
}
}
const setCompany= (id: string, abbr: string) => {
const setCompany = (id: string, abbr: string) => {
companyid.value = id
companyabbr.value = abbr
}
const setSystems = (id: string, abbr: string) => {
systemsid.value = id
systemsabbr.value = abbr
}
// 设置首页
const sethome_page = (page: string) => {
home_page.value = page
}
function restoreTokenTimer() {
const expireAt = Number(localStorage.getItem('token_expire_at') || '0')
if (accessToken.value && expireAt > Date.now()) {
@@ -84,31 +98,70 @@ export const useUserStore = defineStore(
}
}
let isScheduled = false
function scheduleTokenRefresh(expiresIn: number) {
if (refreshTimeout) clearTimeout(refreshTimeout)
if (refreshTimeout) {
clearTimeout(refreshTimeout)
refreshTimeout = null
}
// 提前1分钟刷新
const refreshTime = (expiresIn - 100) * 1000
const refreshTime = (expiresIn - 60) * 1000
if (refreshTime > 0) {
refreshTimeout = setTimeout(refreshTokenAction, refreshTime)
if (!isScheduled) {
isScheduled = true
refreshTimeout = setTimeout(() => {
refreshTokenAction().finally(() => {
isScheduled = false // 重置标志位
})
}, refreshTime)
} else {
console.log('scheduleTokenRefresh is already scheduled, skipping...')
}
} else {
console.error('Invalid refreshTime:', refreshTime)
}
}
async function refreshTokenAction() {
try {
const { data } = await UserService.refreshToken({ refresh_token: refreshToken.value })
setToken(data.access_token, data.refresh_token, data.expires_in)
// 动态计算剩余时间
const currentTime = Date.now()
const expireAt = currentTime + data.expires_in * 1000
const leftSeconds = Math.floor((expireAt - currentTime) / 1000)
setToken(data.access_token, data.refresh_token, leftSeconds)
} catch (e) {
console.error('Token refresh failed:', e)
logOut()
}
}
const logOut = () => {
// 清理徽标系统
import('@/composables/useMenuBadge').then(({ useMenuBadge, resetGlobalInitialized }) => {
const { cleanupBadgeSystem, clearAllMenuBadges } = useMenuBadge()
clearAllMenuBadges()
cleanupBadgeSystem()
resetGlobalInitialized()
})
// 清理徽标store中的其他用户数据
import('@/store/modules/menuBadge').then(({ useMenuBadgeStore }) => {
const menuBadgeStore = useMenuBadgeStore()
menuBadgeStore.cleanupOtherUserData()
})
// 清空所有本地缓存
localStorage.clear();
sessionStorage.clear();
localStorage.clear()
sessionStorage.clear()
systemsabbr.value = ''
systemsid.value = ''
companyabbr.value = ''
companyid.value = ''
home_page.value = ''
info.value = {}
isLogin.value = false
isLock.value = false
@@ -116,17 +169,13 @@ export const useUserStore = defineStore(
accessToken.value = ''
refreshToken.value = ''
if (refreshTimeout) clearTimeout(refreshTimeout) // 清理定时器
// 清理工作台标签页
useWorktabStore().clearAll()
// 清理sessionStorage中的用户信息缓存
sessionStorage.removeItem('lastUserInfo')
sessionStorage.removeItem('iframeRoutes')
// 重置路由状态
resetRouterState(router)
// 跳转到登录页
router.push(RoutesAlias.Login)
}
@@ -139,6 +188,9 @@ export const useUserStore = defineStore(
info,
companyid,
companyabbr,
systemsid,
systemsabbr,
home_page,
searchHistory,
loginType,
accessToken,
@@ -155,7 +207,9 @@ export const useUserStore = defineStore(
setLockPassword,
setToken,
setCompany,
setSystems,
setLoginType,
sethome_page,
logOut,
restoreTokenTimer
}
@@ -163,7 +217,10 @@ export const useUserStore = defineStore(
{
persist: {
key: 'user',
storage: localStorage
}
storage: localStorage,
afterRestore: (ctx: { store: { restoreTokenTimer: () => void } }) => {
ctx.store.restoreTokenTimer()
}
} as any
}
)
+9 -8
View File
@@ -4,7 +4,7 @@ import { HOME_PAGE } from '@/router/routesAlias'
import { router } from '@/router'
import { LocationQueryRaw, Router } from 'vue-router'
import { WorkTab } from '@/types'
import { useUserStore } from '@/store/modules/user'
interface WorktabState {
current: Partial<WorkTab>
opened: WorkTab[]
@@ -144,6 +144,7 @@ export const useWorktabStore = defineStore(
* 关闭指定的选项卡
*/
const removeTab = (path: string): void => {
const { home_page } = useUserStore()
const targetTab = getTab(path)
const targetIndex = findTabIndex(path)
@@ -167,9 +168,9 @@ export const useWorktabStore = defineStore(
// 如果关闭后无标签页,跳转首页
if (!hasOpenedTabs.value) {
if (path !== HOME_PAGE) {
if (path !== home_page) {
current.value = {}
safeRouterPush({ path: HOME_PAGE })
safeRouterPush({ path: home_page })
}
return
}
@@ -287,12 +288,12 @@ export const useWorktabStore = defineStore(
*/
const removeAll = (): void => {
const hasFixedTabs = opened.value.some((tab) => tab.fixedTab)
const { home_page } = useUserStore()
// 获取可关闭的标签页
const closableTabs = opened.value.filter((tab) => {
if (!isTabClosable(tab)) return false
// 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页
return hasFixedTabs || tab.path !== HOME_PAGE
return hasFixedTabs || tab.path !== home_page
})
if (closableTabs.length === 0) {
@@ -305,18 +306,18 @@ export const useWorktabStore = defineStore(
// 保留不可关闭的标签页和首页(当没有固定标签时)
opened.value = opened.value.filter((tab) => {
return !isTabClosable(tab) || (!hasFixedTabs && tab.path === HOME_PAGE)
return !isTabClosable(tab) || (!hasFixedTabs && tab.path === home_page)
})
// 处理激活状态
if (!hasOpenedTabs.value) {
current.value = {}
safeRouterPush({ path: HOME_PAGE })
safeRouterPush({ path: home_page })
return
}
// 选择激活的标签页:优先首页,其次第一个可用标签
const homeTab = opened.value.find((tab) => tab.path === HOME_PAGE)
const homeTab = opened.value.find((tab) => tab.path === home_page)
const targetTab = homeTab || opened.value[0]
current.value = targetTab
+1 -1
View File
@@ -9,7 +9,7 @@ declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElButton: (typeof import('element-plus/es'))['ElButton']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const ElMessageBox: (typeof import('element-plus/es'))['ElMessageBox']
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
const ElNotification: (typeof import('element-plus/es'))['ElNotification']
const ElPopconfirm: (typeof import('element-plus/es'))['ElPopconfirm']
const ElPopover: (typeof import('element-plus/es'))['ElPopover']
+21
View File
@@ -7,6 +7,8 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
'副本': typeof import('./../components/core/layouts/art-page-content/index - 副本.vue')['default']
ApprovalTimeline: typeof import('./../components/custom/ApprovalTimeline.vue')['default']
ArtBackToTop: typeof import('./../components/core/base/art-back-to-top/index.vue')['default']
ArtBarChart: typeof import('./../components/core/charts/ArtBarChart.vue')['default']
ArtBarChartCard: typeof import('./../components/core/cards/art-bar-chart-card/index.vue')['default']
@@ -72,12 +74,16 @@ declare module 'vue' {
CommentItem: typeof import('./../components/custom/comment-widget/widget/CommentItem.vue')['default']
CommentWidget: typeof import('./../components/custom/comment-widget/index.vue')['default']
ContainerSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default']
copy: typeof import('./../components/custom/ProcurementDetailView copy.vue')['default']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCalendar: typeof import('element-plus/es')['ElCalendar']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@@ -93,8 +99,11 @@ declare module 'vue' {
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElLoading: typeof import('element-plus/es')['ElLoading']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
@@ -117,16 +126,27 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
FilePreview: typeof import('./../components/custom/FilePreview.vue')['default']
HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
'Index - 副本': typeof import('./../components/core/layouts/art-page-content/index - 副本.vue')['default']
InternalStaffView: typeof import('./../components/custom/InternalStaffView.vue')['default']
InternalStaffViews: typeof import('./../components/custom/InternalStaffViews.vue')['default']
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']
MenuLayoutSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue')['default']
MenuStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue')['default']
MultiFileUpload: typeof import('./../components/core/forms/MultiFileUpload.vue')['default']
OrderDetailContent: typeof import('./../components/custom/OrderDetailContent.vue')['default']
PrintPreview: typeof import('./../components/custom/PrintPreview.vue')['default']
ProcurementDetailView: typeof import('./../components/custom/ProcurementDetailView.vue')['default']
'ProcurementDetailView copy': typeof import('./../components/custom/ProcurementDetailView copy.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default']
@@ -135,6 +155,7 @@ declare module 'vue' {
SettingItem: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default']
SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default']
ThemeSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default']
WorkChatDrawer: typeof import('./../components/custom/chat/WorkChatDrawer.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+1
View File
@@ -28,6 +28,7 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
authList?: Array<{
title: string
auth_mark: string
authMark?: string // 兼容旧版
}>
/** 是否为一级菜单 */
isFirstLevel?: boolean
+46 -2
View File
@@ -29,6 +29,32 @@ declare namespace Api {
/** 启用状态 */
type EnableStatus = '1' | '2'
interface CommonInfo {
data: never[]
}
/** 列表数据 */
interface CommonListData {
records: CommonInfo[]
current: number
size: number
total: number
}
interface CommonListDatad {
data: CommonInfo[]
current: number
size: number
total: number
}
interface AddResponse {
code: string
message: string
}
interface CommonListDatad1 {
data: never[]
message: string
}
}
/** 认证类型 */
@@ -116,17 +142,19 @@ declare namespace Api {
interface AddParams {
categorie_id: any
content: any
attachment:any
attachment: any
}
interface AddResponse {
code: string
message: string
status: any
data?: any
}
/** 修改状态参数 */
interface StatusParams {
id: any
msg: any
status:any
status: any
}
//分类
interface CategorieInfo {
@@ -157,4 +185,20 @@ declare namespace Api {
total: number
}
}
namespace Crm {
/** 信息 */
interface CrmInfo {
data: never[]
}
/** 列表数据 */
interface CrmListData {
country_id: any
channel_id: any
data: CrmInfo[]
current: number
size: number
total: number
}
}
}
+1
View File
@@ -47,6 +47,7 @@ axiosInstance.interceptors.request.use(
const { language } = useUserStore()
const { loginType } = useUserStore()
const { companyabbr } = useUserStore()
const { systemsabbr } = useUserStore()
request.headers.set('Fdesk-Language', language)
request.headers.set('Fdesk-Corp-Abbr', companyabbr)
request.headers.set('Fdesk-System-Code', 'fdesk')

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