56 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
163 changed files with 51681 additions and 6006 deletions
+2 -1
View File
@@ -309,6 +309,7 @@
"watchWithFilter": true,
"whenever": true,
"ElMessage": true,
"ElTag": true
"ElTag": true,
"ElMessageBox": true
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ VITE_BASE_URL = /
# VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default
# 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
@@ -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)备货单如需同等能力,接口字段一致,可一并支持。
+1
View File
@@ -59,6 +59,7 @@
"@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",
+1895 -5387
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -14,6 +14,7 @@
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, accessToken } = storeToRefs(userStore)
@@ -151,6 +152,17 @@
// 监听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(无需刷新)
@@ -165,6 +177,12 @@
) {
initWebSocket(token)
}
// 重新初始化徽标系统(用户重新登录时)
import('@/composables/useMenuBadge').then(({ useMenuBadge }) => {
const { initBadgeSystem } = useMenuBadge()
initBadgeSystem()
})
} else {
// 退出登录时,关闭并重置
const { closeWebSocket, resetWebSocketReconnect } = await import('@/utils/sys/wsClient')
@@ -218,7 +236,7 @@
.el-dialog__footer {
background: transparent !important;
position: relative;
z-index: 1;
z-index: inherit;
}
.el-dialog__header {
display: flex;
@@ -241,4 +259,7 @@
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
})
}
}
+134 -6
View File
@@ -4,37 +4,43 @@ export class manageService {
//仪表盘 总
static statisticswhole() {
return request.get<any>({
url: '/admin/inquiry/statistics/whole'
// url: '/admin/inquiry/statistics/whole'
url: 'admin/crm/statistics/whole'
})
}
//各渠道统计
static channelcount() {
return request.get<any>({
url: '/admin/inquiry/statistics/channel/count'
// 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/inquiry/statistics/country/count'
url: '/admin/crm/statistics/country/count'
})
}
//跟单人统计
static trackcount() {
return request.get<any>({
url: '/admin/inquiry/statistics/track/count'
// 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/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/inquiry/statistics/inquiry/count',
url: '/admin/crm/statistics/customer/count',
params
})
}
@@ -205,4 +211,126 @@ export class manageService {
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
})
}
}
+145
View File
@@ -35,4 +35,149 @@ export class commonService {
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'
})
}
}
+114 -1
View File
@@ -55,7 +55,7 @@ export class CrmService {
}
//国家下拉
static getCountry() {
return request.get<Api.Brand.BrandInfo>({
return request.get<any>({
url: '/crm/customer/getCountry'
})
}
@@ -207,4 +207,117 @@ export class CrmService {
// 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
})
}
}
+100
View File
@@ -245,4 +245,104 @@ export class systemService {
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
})
}
}
+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
})
}
}
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: 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>
@@ -334,13 +334,8 @@
const goPage = (path: string) => {
router.push(path)
}
const { systemsid } = useUserStore()
const gouserInfo = () => {
if (systemsid == '1') {
router.push('/admin/user/user_info')
} else {
router.push('/system/user-center')
}
router.push('/admin/user/user_info')
}
// const toDocs = () => {
@@ -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,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))
@@ -6,7 +6,7 @@
<RouterView
v-if="isRefresh"
:key="$route.fullPath"
:key="$route.path"
v-slot="{ Component, route }"
:style="{ minHeight: containerMinHeight }"
>
@@ -16,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>
@@ -37,6 +38,7 @@
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
const { keepAliveExclude } = storeToRefs(useWorktabStore())
const { containerMinHeight } = useCommon()
const containerStyle = computed(() => {
+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)
}
})
+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()
+124
View File
@@ -11,6 +11,22 @@ import { AppRouteRecord } from '@/types/router'
* 可以是 i18n 的 key,也可以是字符串,比如:'用户列表'
*/
export const asyncRoutes: AppRouteRecord[] = [
{
path: '/dashboard/console/finance',
name: 'Consolefinance',
component: RoutesAlias.consolefinance,
meta: {
title: '仪表盘'
}
},
{
path: '/dashboard/console/backoffice',
name: 'Backoffice',
component: RoutesAlias.backoffice,
meta: {
title: '仪表盘'
}
},
{
path: '/admin/inquiry_system',
name: 'Inquiry_system',
@@ -65,6 +81,114 @@ export const asyncRoutes: AppRouteRecord[] = [
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,
+9 -1
View File
@@ -62,7 +62,15 @@ export enum RoutesAlias {
UserCenter = '/system/user-center', // 用户中心
inquirySystem = '/admin/inquiry_system', // 询单系统
customer = '/admin/customer', // 管理端客户列表
customeraudit = '/admin/customer/customeraudit' // 管理端待审客户列表
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
}
)
+14
View File
@@ -140,6 +140,20 @@ export const useUserStore = defineStore(
}
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()
+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 -1
View File
@@ -50,7 +50,7 @@ axiosInstance.interceptors.request.use(
const { systemsabbr } = useUserStore()
request.headers.set('Fdesk-Language', language)
request.headers.set('Fdesk-Corp-Abbr', companyabbr)
request.headers.set('Fdesk-System-Code', systemsabbr)
request.headers.set('Fdesk-System-Code', 'fdesk')
request.headers.set('Fdesk-Login-Type', loginType)
// 设置 token 和 请求头
if (accessToken) {
+186
View File
@@ -0,0 +1,186 @@
/**
* 菜单徽标管理工具
* 用于管理菜单徽标功能
*/
import { useMenuBadgeStore } from '@/store/modules/menuBadge'
/**
* 菜单徽标管理类
*/
export class MenuBadgeTest {
private menuBadgeStore = useMenuBadgeStore()
/**
* 直接更新徽标
*/
updateBadge(path: string, count: number, type: 'dot' | 'text' = 'text') {
// console.log(`直接更新徽标: ${path} = ${count} (${type})`)
this.menuBadgeStore.updateBadgeData(path, count, type)
}
/**
* 批量更新徽标
*/
batchUpdateBadges(updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>) {
// console.log('批量更新徽标:', updates)
this.menuBadgeStore.batchUpdateBadgeData(updates)
}
/**
* 清除徽标
*/
clearBadge(path: string) {
// console.log(`清除徽标: ${path}`)
this.menuBadgeStore.clearBadge(path)
}
/**
* 清除所有徽标
*/
clearAllBadges() {
// console.log('清除所有徽标')
this.menuBadgeStore.clearAllBadges()
}
/**
* 获取徽标状态
*/
getBadgeStatus(path: string) {
const data = this.menuBadgeStore.getBadgeData(path)
// console.log(`徽标状态 ${path}:`, data)
return data
}
/**
* 获取所有徽标状态
*/
getAllBadgeStatus() {
const allData = this.menuBadgeStore.allBadgeData
// console.log('所有徽标状态:', allData)
return allData
}
/**
* 获取待处理更新数量
*/
getPendingUpdatesCount() {
const count = this.menuBadgeStore.getPendingUpdatesCount()
// console.log(`待处理更新数量: ${count}`)
return count
}
/**
* 设置页面加载状态
*/
setPageReady(ready: boolean) {
// console.log(`设置页面加载状态: ${ready}`)
this.menuBadgeStore.setPageReady(ready)
}
/**
* 检查实际菜单路径
*/
checkActualMenuPaths() {
// console.log('检查实际菜单路径...')
try {
// 获取菜单store
import('@/store/modules/menu')
.then(({ useMenuStore }) => {
const menuStore = useMenuStore()
this.printMenuPaths(menuStore.menuList)
})
.catch((error) => {
console.error('动态导入菜单store失败:', error)
})
} catch (error) {
console.error('检查菜单路径失败:', error)
}
}
private printMenuPaths(menuList: any[]) {
// console.log('当前菜单列表:', menuList)
// 递归打印所有菜单路径
const printMenuPaths = (menus: any[], level = 0) => {
menus.forEach((menu) => {
// const indent = ' '.repeat(level)
// console.log(`${indent}${menu.path} - ${menu.meta?.title}`)
if (menu.children && menu.children.length > 0) {
printMenuPaths(menu.children, level + 1)
}
})
}
if (menuList && menuList.length > 0) {
printMenuPaths(menuList)
} else {
console.log('菜单列表为空,可能还未加载完成')
}
}
/**
* 运行基础功能测试
*/
runBasicTest() {
// console.log('开始运行基础功能测试...')
// 1. 测试页面未加载完成时的更新
// console.log('\n=== 测试页面未加载完成时的更新 ===')
this.setPageReady(false)
this.updateBadge('/admin/approval', 3, 'text')
this.updateBadge('/admin/stock', 1, 'dot')
// 2. 设置页面加载完成
// console.log('\n=== 设置页面加载完成 ===')
this.setPageReady(true)
// 3. 测试批量更新
// console.log('\n=== 测试批量更新 ===')
this.batchUpdateBadges([
{ path: '/admin/approval', count: 3, type: 'text' },
{ path: '/admin/stock', count: 1, type: 'dot' }
])
// 4. 显示最终状态
// console.log('\n=== 最终状态 ===')
this.getAllBadgeStatus()
// console.log('\n基础功能测试完成!')
}
/**
* 检查WebSocket连接状态
*/
checkWebSocketStatus() {
// console.log('检查WebSocket连接状态...')
// 检查全局WebSocket状态函数
if (typeof window !== 'undefined' && (window as any).getWebSocketStatus) {
const status = (window as any).getWebSocketStatus()
console.log('WebSocket状态:', status)
} else {
console.log('WebSocket状态函数不可用')
}
// 检查菜单徽标系统初始化状态
const pendingCount = this.menuBadgeStore.getPendingUpdatesCount()
console.log('待处理的徽标更新数量:', pendingCount)
if (pendingCount > 0) {
console.log('待处理的更新:', this.menuBadgeStore.getPendingUpdates())
}
}
}
// 创建全局测试实例
const menuBadgeTest = new MenuBadgeTest()
// 将测试工具挂载到全局对象,方便在控制台使用
if (typeof window !== 'undefined') {
// @ts-expect-error - 挂载到全局对象用于调试
window.menuBadgeTest = menuBadgeTest
}
export default menuBadgeTest
+2 -2
View File
@@ -98,10 +98,10 @@ class StorageCompatibilityManager {
}
/**
* 检查是否在登录页面
* 检查是否在登录页面或电子签名页面
*/
private isOnLoginPage(): boolean {
return location.href.includes(RoutesAlias.Login)
return location.href.includes(RoutesAlias.Login) || location.href.includes('/auth/signature/mobile')
}
/**
+241 -10
View File
@@ -1,6 +1,8 @@
import mittBus from './mittBus'
import mittBus from './mittBus'
import { useUserStore } from '@/store/modules/user'
import { ElNotification } from 'element-plus'
import { ElNotification, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { ref } from 'vue'
type JsonValue = Record<string, any> | any[]
@@ -18,6 +20,7 @@ let isConnecting = false
let lastInitToken: string | null = null
let lastInitAt = 0
const INIT_DEBOUNCE_MS = 1200
let isAlertShowing = false
// 生成唯一ID
const generateTraceId = (): string => {
@@ -95,9 +98,144 @@ const handleMessage = (event: MessageEvent) => {
try {
data = JSON.parse(event.data)
} catch {
// 忽略解析错误
// console.warn('WebSocket消息解析失败:', error, '原始数据:', event.data)
// 对于无法解析的消息,仍然尝试处理原始数据
data = { event: 'unknown', data: event.data }
}
console.log('WebSocket message:', data)
// console.log('WebSocket message:', data)
// 判断系统初始化检查
if (data.event == 'system.init.check') {
const { systemsid } = useUserStore()
// 检查页面是否已加载完成
if (document.readyState !== 'complete') {
console.warn('页面未完全加载,延迟处理系统检查消息')
// 延迟处理,等待页面加载完成
setTimeout(() => {
handleMessage(event)
}, 1000)
return
}
// 检查 router 是否可用
let router: any = null
let currentPath = ''
let isOnMobileSignature = false
let isOnUserInfo = false
let isOnUserCenter = false
try {
router = useRouter()
// 检查 router.currentRoute 是否可用
if (router.currentRoute) {
currentPath = router.currentRoute.value?.path || ''
isOnMobileSignature = currentPath.startsWith('/auth/signature/mobile')
isOnUserInfo = currentPath.startsWith('/system/user-center')
isOnUserCenter = currentPath.startsWith('/admin/user/user_info')
} else {
// Router currentRoute 未初始化,使用 window.location 作为备选方案
currentPath = window.location.pathname || ''
isOnMobileSignature = currentPath.startsWith('/auth/signature/mobile')
isOnUserInfo = currentPath.startsWith('/system/user-center')
isOnUserCenter = currentPath.startsWith('/admin/user/user_info')
// console.warn('Router currentRoute 未初始化,使用 window.location 作为备选方案')
}
} catch {
// Router 完全未初始化,使用 window.location 作为备选方案
currentPath = window.location.pathname || ''
isOnMobileSignature = currentPath.startsWith('/auth/signature/mobile')
isOnUserInfo = currentPath.startsWith('/system/user-center')
isOnUserCenter = currentPath.startsWith('/admin/user/user_info')
// console.warn('Router 未初始化,使用 window.location 作为备选方案:', error)
}
// 排除移动端电子签名页,不弹出提示框
if (isOnMobileSignature || isOnUserInfo || isOnUserCenter) {
return
}
// 接收到消息就弹出提示框(排除移动端电子签名页)
if (data.data) {
const { is_sign } = data.data
const msgs = ref('尚未设置【签名】,请前往个人中心完成操作!')
// 根据检查结果确定提示信息
let alertMessage = ''
let shouldShowAlert = false
// 构建状态标识
// const statusKey = `${is_report_to}-${is_office_clerk}-${is_sign}`
// switch (statusKey) {
// case 'false-false-false':
// alertMessage = msgs.value
// shouldShowAlert = true
// break
// case 'true-false-true':
// alertMessage = '尚未操作【绑定直属上级、签名】,请前往个人中心完成操作!'
// shouldShowAlert = true
// break
// case 'true-false-false':
// alertMessage = '尚未操作【绑定内勤、签名】】,请前往个人中心完成操作!'
// shouldShowAlert = true
// break
// case 'true-true-false':
// alertMessage = '尚未操作【签名】,请前往个人中心完成操作!'
// shouldShowAlert = true
// break
// case 'false-true-true':
// alertMessage = '尚未操作【直属上级】,请前往个人中心完成操作!'
// shouldShowAlert = true
// break
// case 'false-true-false':
// alertMessage = '尚未操作【直属上级,签名】,请前往个人中心完成操作!'
// shouldShowAlert = true
// break
// case 'false-false-true':
// alertMessage = '尚未操作【直属上级,内勤】,请前往个人中心完成操作!'
// shouldShowAlert = true
// break
// default:
// // 其他情况不弹出提示
// shouldShowAlert = false
// break
// }
if (is_sign == false) {
alertMessage = msgs.value
shouldShowAlert = true
}
// 只有在需要显示提示且当前没有弹出框的情况下才弹出提示框
if (shouldShowAlert && !isAlertShowing) {
isAlertShowing = true
ElMessageBox.alert(alertMessage, '提醒', {
confirmButtonText: '确定',
callback: () => {
isAlertShowing = false
// 如果 router 可用,使用 router 跳转
if (router && router.push) {
if (systemsid == '1') {
router.push('/admin/user/user_info')
} else {
router.push('/system/user-center')
}
} else {
// Router 不可用,使用 window.location 跳转
const targetPath = systemsid == '1' ? '/admin/user/user_info' : '/system/user-center'
window.location.href = targetPath
}
}
}).catch(() => {
// 如果弹出框被意外关闭,重置状态
isAlertShowing = false
})
} else if (shouldShowAlert && isAlertShowing) {
// 已有弹窗显示,避免重复弹出
}
}
}
// 处理通知消息
if (data.event == 'notify.receive') {
ElNotification({
title: data.data.notice_data.message.title || '通知',
@@ -106,22 +244,97 @@ const handleMessage = (event: MessageEvent) => {
})
mittBus.emit('notification-data-changed', 1)
}
// 处理菜单徽标更新消息
if (data.event && data.event.includes('menu.badge')) {
// console.log('收到菜单徽标更新消息:', data)
// 直接触发徽标更新事件
mittBus.emit('menu-badge-update', data)
}
// 处理审批相关消息
if (data.event && data.event.includes('approval')) {
// 检查是否是数组格式的徽标数据或待办数量消息
if (
Array.isArray(data.data) ||
Array.isArray(data) ||
data.event.includes('.todo.counts') ||
data.event.includes('.badge.counts')
) {
// 数组格式的徽标数据或待办数量消息,触发菜单徽标更新事件
mittBus.emit('menu-badge-update', data)
// 标记已处理,避免在后续通用处理中重复处理
data._badgeProcessed = true
} else {
// 其他审批消息触发审批徽标更新事件
mittBus.emit('approval-badge-update', data)
}
}
// 处理库存相关消息
if (data.event && data.event.includes('stock')) {
// console.log('收到库存相关消息:', data)
// 触发库存徽标更新事件
mittBus.emit('stock-badge-update', data)
}
// 处理通知相关消息
if (data.event && data.event.includes('notify')) {
// console.log('收到通知相关消息:', data)
// 触发通知徽标更新事件
mittBus.emit('notification-badge-update', data)
}
// 处理其他可能的徽标相关消息
// 检查是否是数组格式的徽标数据(无论是否有 event 字段)
// 如果已经被处理过(如审批消息),则跳过
if (!data._badgeProcessed) {
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
mittBus.emit('menu-badge-update', data)
} else if (data.event && (data.event.includes('badge') || data.event.includes('count'))) {
// 尝试作为通用徽标更新处理
mittBus.emit('menu-badge-update', data)
}
}
// 发送原始消息到事件总线
mittBus.emit('ws:message', data)
// 检查WebSocket连接状态,如果连接异常则记录
if (socket && socket.readyState !== WebSocket.OPEN) {
// console.warn('WebSocket连接状态异常,当前状态:', socket.readyState, '消息可能丢失')
}
}
// 设置WebSocket事件处理器
const setupWebSocketHandlers = (ws: WebSocket) => {
ws.onopen = () => {
console.log('WebSocket连接已建立')
isInitialConnection = false
isConnecting = false
startHeartbeat()
mittBus.emit('ws:open')
sendPing()
// 连接建立成功后推送一个连接确认消息
const connectMessage = {
event: 'system.connect',
data: {
timestamp: Date.now(),
message: 'WebSocket连接已建立'
},
trace_id: generateTraceId()
}
try {
ws.send(JSON.stringify(connectMessage))
} catch (error) {
console.error('发送连接确认消息失败:', error)
}
}
ws.onmessage = handleMessage
ws.onerror = (err) => {
console.error('WebSocket连接错误:', err)
mittBus.emit('ws:error', err)
isConnecting = false
try {
@@ -134,6 +347,7 @@ const setupWebSocketHandlers = (ws: WebSocket) => {
}
ws.onclose = () => {
console.log('WebSocket连接已关闭')
mittBus.emit('ws:close')
isConnecting = false
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
@@ -155,7 +369,6 @@ const scheduleReconnect = () => {
cleanup()
const delay = getReconnectDelay()
console.log(`WebSocket重连尝试 ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS},延迟: ${delay}ms`)
reconnectTimer = setTimeout(() => {
const { accessToken } = useUserStore()
if (accessToken && !isConnecting) {
@@ -187,32 +400,50 @@ const scheduleReconnect = () => {
// 初始化WebSocket连接
export const initWebSocket = (token: string) => {
try {
if (!token) return
if (!token) {
console.log('WebSocket初始化失败:没有token')
return
}
const now = Date.now()
// 简单防抖:在短时间内重复触发则跳过
if (now - lastInitAt < INIT_DEBOUNCE_MS) {
console.log('WebSocket初始化被防抖跳过')
return
}
// 避免并发初始化:已在连接中或已连接且 token 未变化则跳过
if (isConnecting) return
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
if (isConnecting) {
console.log('WebSocket正在连接中,跳过重复初始化')
return
}
if (
socket &&
(socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
) {
if (lastInitToken === token) {
console.log('WebSocket已连接且token未变化,跳过初始化')
return
}
// token 变更:需要切换连接
try { socket.close() } catch {}
console.log('WebSocket token变更,关闭旧连接')
try {
socket.close()
} catch {
// 忽略关闭错误
}
socket = null
}
cleanup()
const url = `${WS_BASE}${token}`
console.log('正在初始化WebSocket连接:', url)
isConnecting = true
lastInitToken = token
lastInitAt = now
socket = new WebSocket(url)
setupWebSocketHandlers(socket)
} catch (e) {
console.error('WebSocket初始化失败:', e)
mittBus.emit('ws:error', e)
isConnecting = false
scheduleReconnect()
@@ -270,4 +501,4 @@ export const getWebSocketStatus = () => ({
isInitialConnection,
isConnecting,
lastInitToken
})
})
@@ -0,0 +1,810 @@
<template>
<div class="borrow-approval-page">
<div class="borrow-toolbar boxshadow">
<div class="toolbar-right">
<el-select
v-model="searchForm.status"
placeholder="选择状态"
clearable
style="width: 150px; margin-right: 12px"
@change="handleSearch"
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="searchForm.keyword"
placeholder="请输入关键字"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
>
<template #suffix>
<el-icon class="clickable" @click="handleSearch"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="boxshadow">
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="申请单号" prop="order_no" min-width="160" />
<ElTableColumn label="申请人" prop="applicant_name" min-width="120" />
<ElTableColumn label="借用物品" prop="item" min-width="200">
<template #default="scope">
<span>{{ getItemNames(scope.row.item) }}</span>
</template>
</ElTableColumn>
<ElTableColumn
label="借用原因"
prop="borrow_reason"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="使用开始日期" prop="use_start_date" min-width="120" />
<ElTableColumn label="使用结束日期" prop="use_end_date" min-width="120" />
<ElTableColumn label="状态" prop="status" min-width="120">
<template #default="scope">
<ElTag :type="getStatusType(scope.row.status)">
{{ getStatusLabel(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" min-width="160" />
<ElTableColumn label="操作" fixed="right" min-width="180" align="center">
<template #default="scope">
<ElRow :gutter="10" justify="center">
<el-link
type="primary"
underline="never"
@click="handleViewDetail(scope.row)"
style="margin-right: 8px"
v-if="hasAuth('borrow_approval@show')"
>
<i class="iconfont-sys">&#xe76c;</i> 查看详情
</el-link>
<el-link
v-if="canApprove(scope.row) && hasAuth('borrow_approval@approve')"
type="success"
underline="never"
@click="handleApprove(scope.row)"
>
<i class="iconfont-sys">&#xe6bf;</i> 审批
</el-link>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="借用申请详情"
width="800px"
:close-on-click-modal="false"
>
<div v-if="detailData.id" class="detail-content">
<!-- 基本信息 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>基本信息</span>
</div>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>申请单号</label>
<span>{{ detailData.order_no }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>申请人</label>
<span>{{ detailData.applicant_name }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>使用开始日期</label>
<span>{{ detailData.use_start_date }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>使用结束日期</label>
<span>{{ detailData.use_end_date }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row">
<ElCol :span="24">
<div class="info-item">
<label>借用原因</label>
<span>{{ detailData.borrow_reason }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row" v-if="detailData.remark">
<ElCol :span="24">
<div class="info-item">
<label>备注</label>
<span>{{ detailData.remark }}</span>
</div>
</ElCol>
</ElRow>
</div>
<!-- 借用物品 -->
<div class="info-section" v-if="detailData.item && detailData.item.length > 0">
<div class="section-title">
<div class="title-bar"></div>
<span>借用物品</span>
</div>
<div class="item-list">
<div
v-for="(item, index) in detailData.item"
:key="index"
class="item-card"
>
<div class="item-row">
<span class="item-label">物品名称</span>
<span class="item-value">{{ item.item_name_snap }}</span>
</div>
<div class="item-row">
<span class="item-label">申请数量</span>
<span class="item-value">{{ item.request_qty }}</span>
</div>
</div>
</div>
</div>
<!-- 附件 -->
<div class="info-section" v-if="detailData.attachment && detailData.attachment.length > 0">
<div class="section-title">
<div class="title-bar"></div>
<span>附件</span>
</div>
<div class="image-attachment-list">
<div
v-for="(file, index) in detailData.attachment"
:key="index"
class="image-attachment-item"
>
<el-image
:src="file.file_path_pull"
:preview-src-list="getImagePreviewUrls(detailData.attachment)"
:initial-index="Number(index)"
fit="cover"
class="attachment-image"
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
<div class="image-name">{{ file.file_name }}</div>
</div>
</div>
</div>
<!-- 审批记录 -->
<div class="info-section" v-if="detailData.approve && detailData.approve.length > 0">
<div class="section-title">
<div class="title-bar"></div>
<span>审批记录</span>
</div>
<el-timeline>
<el-timeline-item
v-for="(record, index) in detailData.approve"
:key="index"
:timestamp="record.action_at != 0 ? record.action_at : ''"
placement="top"
>
<div class="timeline-content">
<div class="timeline-header">
<span class="approver">{{ record.approver_name }}</span>
<ElTag :type="getActionType(record.action)" size="small">
{{ getActionLabel(record.action) }}
</ElTag>
</div>
<div v-if="record.comment" class="timeline-comment">
{{ record.comment }}
</div>
</div>
</el-timeline-item>
</el-timeline>
</div>
</div>
</el-dialog>
<!-- 审批弹窗 -->
<el-dialog
v-model="approvalDialogVisible"
title="借用申请审批"
width="800px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<div v-if="approvalDetail.id" class="approval-content">
<!-- 申请详情展示 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>申请信息</span>
</div>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>申请单号</label>
<span>{{ approvalDetail.order_no }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>申请人</label>
<span>{{ approvalDetail.applicant_name }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>使用开始日期</label>
<span>{{ approvalDetail.use_start_date }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>使用结束日期</label>
<span>{{ approvalDetail.use_end_date }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row">
<ElCol :span="24">
<div class="info-item">
<label>借用原因</label>
<span>{{ approvalDetail.borrow_reason }}</span>
</div>
</ElCol>
</ElRow>
</div>
<!-- 借用物品 -->
<div class="info-section" v-if="approvalDetail.item && approvalDetail.item.length > 0">
<div class="section-title">
<div class="title-bar"></div>
<span>借用物品</span>
</div>
<div class="item-list">
<div v-for="(item, index) in approvalDetail.item" :key="index" class="item-card">
<div class="item-row">
<span class="item-label">物品名称</span>
<span class="item-value">{{ item.item_name_snap }}</span>
</div>
<div class="item-row">
<span class="item-label">申请数量</span>
<span class="item-value">{{ item.request_qty }}</span>
</div>
</div>
</div>
</div>
<!-- 审批意见 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>审批意见</span>
</div>
<ElInput
v-model="approvalForm.comment"
type="textarea"
:rows="3"
placeholder="请输入审批意见"
/>
</div>
<!-- 上传附件 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>附件(可选)</span>
</div>
<el-upload
v-model:file-list="approvalFileList"
:action="uploadAction"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
:on-remove="handleUploadRemove"
:limit="5"
multiple
>
<el-button type="primary">点击上传</el-button>
<template #tip>
<div class="el-upload__tip">支持图片、PDF等格式,最多上传5个文件</div>
</template>
</el-upload>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton
type="danger"
plain
size="large"
class="submit-btn"
@click="handleSubmitApprove('reject')"
>
拒绝
</ElButton>
<ElButton
type="primary"
size="large"
class="submit-btn"
@click="handleSubmitApprove('approve')"
>
同意
</ElButton>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadUserFile, UploadProps } from 'element-plus'
import { invitemService } from '@/api/admin/Itemrequisition'
import { useAuth } from '@/composables/useAuth'
import { useUploadHeaders } from '@/composables/useUploadHeaders'
defineOptions({ name: 'BorrowApprovalPage' })
const { hasAuth } = useAuth()
const { uploadHeaders } = useUploadHeaders()
// 上传地址
const uploadAction = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=borrow_approval`
// 状态选项
const statusOptions = [
{ label: '待审核', value: 10 },
{ label: '审批中', value: 11 },
{ label: '已审批(待归还)', value: 12 },
{ label: '拒绝', value: 13 }
]
// 获取状态标签颜色
const getStatusType = (status: number): 'success' | 'danger' | 'info' | 'warning' | 'primary' => {
const typeMap: Record<number, 'success' | 'danger' | 'info' | 'warning' | 'primary'> = {
10: 'warning', // 待审核
11: 'primary', // 审批中
12: 'success', // 已审批(待归还)
13: 'danger', // 拒绝
14: 'info', // 申请归还
15: 'primary', // 归还审核中
16: 'success', // 部分归还
17: 'success', // 已全部归还
18: 'danger' // 已拒绝
}
return typeMap[status] || 'info'
}
// 获取状态文本
const getStatusLabel = (status: number) => {
const labelMap: Record<number, string> = {
10: '待审核',
11: '审批中',
12: '已审批(待归还)',
13: '拒绝',
14: '申请归还',
15: '归还审核中',
16: '部分归还',
17: '已全部归还',
18: '已拒绝'
}
return labelMap[status] || '未知'
}
// 判断是否可以审批
const canApprove = (row: any) => {
return row.status === 10 || row.status === 11 || row.status === 14 || row.status === 15
}
// 获取审批动作标签颜色
const getActionType = (action: number): 'success' | 'danger' | 'info' | 'warning' | 'primary' => {
const typeMap: Record<number, 'success' | 'danger' | 'info' | 'warning' | 'primary'> = {
0: 'info', // 待审核
1: 'success', // 同意
2: 'danger' // 拒绝
}
return typeMap[action] || 'info'
}
// 获取审批动作文本
const getActionLabel = (action: number) => {
const labelMap: Record<number, string> = {
0: '待审核',
1: '同意',
2: '拒绝'
}
return labelMap[action] || '未知'
}
// 获取物品名称列表
const getItemNames = (items: any[]) => {
if (!items || items.length === 0) return '-'
return items.map((item) => item.item_name_snap).join('、')
}
// 获取图片预览URL列表
const getImagePreviewUrls = (attachments: any[]) => {
if (!attachments || attachments.length === 0) return []
return attachments.map((item) => item.file_path_pull)
}
const loading = ref(false)
const tableRef = ref()
const tableData = ref<any[]>([])
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined
})
// 详情弹窗数据
const detailDialogVisible = ref(false)
const detailData = ref<any>({})
// 审批弹窗数据
const approvalDialogVisible = ref(false)
const currentRowId = ref<number | null>(null)
const approvalDetail = ref<any>({})
const approvalForm = reactive({
comment: '',
attachment: [] as any[]
})
const approvalFileList = ref<UploadUserFile[]>([])
// 上传成功处理
const handleUploadSuccess: UploadProps['onSuccess'] = (response) => {
if (response.code === 200) {
approvalForm.attachment.push({
original_name: response.data.original_name || '文件',
file_path: response.data.file_path || response.data.file_url,
file_url: response.data.file_url
})
ElMessage.success('文件上传成功')
}
}
// 删除文件处理
const handleUploadRemove: UploadProps['onRemove'] = (file) => {
const index = approvalForm.attachment.findIndex(
(item) => item.file_url === file.url || item.original_name === file.name
)
if (index > -1) {
approvalForm.attachment.splice(index, 1)
}
}
onMounted(() => {
getList()
})
// 获取列表数据
const getList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (searchForm.keyword) {
params.keyword = searchForm.keyword
}
if (searchForm.status !== undefined) {
params.status = searchForm.status
}
const { records, current, size, total } = await invitemService.getBorrowList(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.currentPage = 1
getList()
}
// 分页变化
const handleSizeChange = (size: number) => {
pagination.pageSize = size
getList()
}
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
getList()
}
// 查看详情
const handleViewDetail = async (row: any) => {
try {
const detail = await invitemService.getBorrowDetail({ id: row.id })
detailData.value = detail
detailDialogVisible.value = true
} catch (error) {
console.error('获取详情失败:', error)
}
}
// 打开审批弹窗 - 获取完整详情
const handleApprove = async (row: any) => {
try {
const detail = await invitemService.getBorrowDetail({ id: row.id })
currentRowId.value = row.id
approvalDetail.value = detail
approvalForm.comment = ''
approvalForm.attachment = []
approvalFileList.value = []
approvalDialogVisible.value = true
} catch (error) {
console.error('获取详情失败:', error)
}
}
// 提交审批
const handleSubmitApprove = async (action: 'approve' | 'reject') => {
if (!approvalForm.comment) {
ElMessage.warning('请输入审批意见')
return
}
try {
const { message } = await invitemService.borrowApproval({
id: currentRowId.value!,
action,
comment: approvalForm.comment,
attachment: approvalForm.attachment.map((item) => ({
original_name: item.original_name,
file_path: item.file_path
}))
})
ElMessage.success(message)
approvalDialogVisible.value = false
approvalForm.comment = ''
approvalForm.attachment = []
currentRowId.value = null
approvalDetail.value = {}
approvalFileList.value = []
getList()
} catch (error) {
console.error('审批失败:', error)
}
}
</script>
<style scoped lang="scss">
.borrow-approval-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.borrow-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--art-main-bg-color);
.toolbar-right {
display: flex;
align-items: center;
}
}
.clickable {
cursor: pointer;
}
// 详情和审批弹窗样式
.detail-content,
.approval-content {
max-height: 60vh;
overflow-y: auto;
}
.info-section {
margin: 20px 0;
.section-title {
display: flex;
align-items: center;
margin-bottom: 16px;
.title-bar {
width: 4px;
height: 20px;
background: #409eff;
margin-right: 8px;
border-radius: 2px;
}
span {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
}
.info-row {
margin-bottom: 12px;
}
.info-item {
display: flex;
flex-direction: column;
label {
font-size: 13px;
color: #909399;
margin-bottom: 6px;
}
span {
font-size: 14px;
color: #303133;
}
}
// 物品列表
.item-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.item-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
background: #fafafa;
.item-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
&:last-child {
margin-bottom: 0;
}
.item-label {
color: #909399;
}
.item-value {
color: #303133;
font-weight: 500;
}
}
}
// 图片附件列表
.image-attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.image-attachment-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100px;
}
.attachment-image {
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px solid #e4e7ed;
overflow: hidden;
background: #f5f7fa;
:deep(.el-image__inner) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-error {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 32px;
}
.image-name {
font-size: 12px;
color: #606266;
text-align: center;
word-break: break-all;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 审批记录时间轴
.timeline-content {
.timeline-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.approver {
font-weight: 600;
color: #303133;
}
}
.timeline-comment {
font-size: 14px;
color: #606266;
}
}
// 弹窗底部按钮
.dialog-footer {
display: flex;
justify-content: center;
gap: 20px;
.submit-btn {
min-width: 120px;
}
}
</style>
@@ -0,0 +1,929 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
</div>
<div class="boxshadow">
<ElRow>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton @click="showDialog('add')" v-if="hasAuth('item_list@add')" v-ripple
>新增</ElButton
>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="ID" prop="id" width="80" />
<ElTableColumn label="公司" prop="company_name" />
<ElTableColumn label="名称" prop="item_name" />
<ElTableColumn label="物品管理员" prop="manage_user_name" />
<ElTableColumn label="备注" prop="remark" />
<ElTableColumn label="库存数量" prop="stock_qty" />
<ElTableColumn label="扫码领取" prop="allow_qr_claim">
<template #default="scope">
<ElTag :type="scope.row.allow_qr_claim ? 'primary' : 'info'">
{{ scope.row.allow_qr_claim ? '是' : '否' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="领取限制数量" prop="qr_code_num">
<template #default="scope">
{{ scope.row.qr_code_num === 0 ? '不限制' : scope.row.qr_code_num }}
</template>
</ElTableColumn>
<ElTableColumn label="是否耗材" prop="is_consumable" width="180">
<template #default="scope">
<ElTag :type="scope.row.is_consumable ? 'primary' : 'info'">
{{ scope.row.is_consumable ? '是' : '否' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status ? 'primary' : 'info'">
{{ scope.row.status ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" />
<ElTableColumn fixed="right" label="操作" width="160" align="center">
<template #default="scope">
<ElButton link type="primary" @click="showDialog('edit', scope.row)" v-if="hasAuth('item_list@edit')">
编辑
</ElButton>
<ElDropdown trigger="click">
<ElButton link type="primary">
更多
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="generateQrCode(scope.row)">
<el-icon><Document /></el-icon>
{{ scope.row.qr_code_pull ? '查看二维码' : '生成二维码' }}
</ElDropdownItem>
<ElDropdownItem @click="showProxyClaimDialog(scope.row)">
<el-icon><User /></el-icon>
管理员代领
</ElDropdownItem>
<ElDropdownItem @click="showStockLogDialog(scope.row)">
<el-icon><List /></el-icon>
库存记录
</ElDropdownItem>
<ElDropdownItem @click="showQrClaimsDialog(scope.row)">
<el-icon><Tickets /></el-icon>
领取记录
</ElDropdownItem>
<ElDropdownItem v-if="scope.row.qr_code_pull" @click="deleteQrCode(scope.row)">
<el-icon><Delete /></el-icon>
删除二维码
</ElDropdownItem>
<ElDropdownItem divided @click="deleteRole(scope.row)" v-if="hasAuth('item_list@del')">
<el-icon style="color: #f56c6c"><Delete /></el-icon>
<span style="color: #f56c6c">删除物品</span>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<ElDialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增' : '编辑'" align-center>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
style="padding: 5px 20px"
label-width="100px"
>
<ElFormItem label="公司" prop="company_id">
<ElSelect
v-model="form.company_id"
placeholder="请选择公司"
filterable
style="width: 100%"
>
<ElOption
v-for="item in companyList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="名称" prop="item_name">
<ElInput v-model="form.item_name" placeholder="请输入名称" />
</ElFormItem>
<ElFormItem label="物品管理员" prop="manage_user_id">
<ElSelect
v-model="form.manage_user_id"
placeholder="请选择负责人"
filterable
style="width: 100%"
>
<ElOption
v-for="item in manageUserList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<!-- <ElFormItem label="规格型号" prop="spec_model">
<ElInput v-model="form.spec_model" placeholder="请输入规格型号" />
</ElFormItem>
<ElFormItem label="计量单位" prop="unit">
<ElInput v-model="form.unit" placeholder="请输入计量单位" />
</ElFormItem> -->
<ElFormItem label="是否耗材" prop="is_consumable">
<el-radio-group v-model="form.is_consumable">
<el-radio :label="1">是</el-radio>
<el-radio :label="0">否</el-radio>
</el-radio-group>
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput v-model="form.remark" placeholder="请输入备注" />
</ElFormItem>
<ElFormItem label="库存数量" prop="stock_qty">
<ElInputNumber
v-model="form.stock_qty"
:min="0"
placeholder="请输入库存数量"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="扫码领取" prop="allow_qr_claim">
<el-radio-group v-model="form.allow_qr_claim">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
</ElFormItem>
<ElFormItem v-if="form.allow_qr_claim === 1" label="单人领取限制" prop="qr_code_num">
<ElInputNumber
v-model="form.qr_code_num"
:min="0"
placeholder="0为不限制"
style="width: 100%"
/>
<span style="margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px">
单人扫码领取数量限制,0为不限制
</span>
</ElFormItem>
<ElFormItem label="状态" prop="status">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<!-- 二维码弹窗 -->
<ElDialog v-model="qrCodeDialogVisible" title="物品二维码" width="400px" align-center>
<div class="qr-code-container">
<div v-if="qrCodeData" class="qr-code-wrapper">
<img :src="qrCodeData" alt="二维码" class="qr-code-image" />
<p class="qr-code-desc">扫描二维码领取物品</p>
</div>
<div v-else-if="qrCodeLoading" class="qr-code-loading">
<el-icon class="is-loading" :size="40">
<Loading />
</el-icon>
<p>生成中...</p>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeQrCodeDialog">{{ qrCodeData ? '关闭' : '取消' }}</ElButton>
<ElButton v-if="qrCodeData" type="primary" @click="downloadQrCode">下载二维码</ElButton>
</div>
</template>
</ElDialog>
<!-- 管理员代领弹窗 -->
<ElDialog v-model="proxyClaimDialogVisible" title="管理员代领" width="500px" align-center>
<ElForm ref="proxyClaimFormRef" :model="proxyClaimForm" label-width="100px">
<ElFormItem label="选择用户" prop="target_user_ids">
<ElSelect
v-model="proxyClaimForm.target_user_ids"
placeholder="请选择用户"
filterable
multiple
style="width: 100%"
>
<ElOption v-for="item in userList" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="proxyClaimDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleProxyClaimSubmit">确定</ElButton>
</div>
</template>
</ElDialog>
<!-- 库存记录弹窗 -->
<ElDialog v-model="stockLogDialogVisible" title="库存变化记录" width="80%" align-center>
<!-- 搜索表单 -->
<ElForm :model="stockLogSearchForm" inline style="margin-bottom: 16px">
<ElFormItem label="变动类型">
<ElSelect
v-model="stockLogSearchForm.change_type"
placeholder="请选择变动类型"
clearable
style="width: 150px"
>
<ElOption label="入库" :value="1" />
<ElOption label="出库(领用)" :value="2" />
<ElOption label="出库(损耗)" :value="3" />
<ElOption label="入库(归还)" :value="4" />
</ElSelect>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="searchStockLog">查询</ElButton>
<ElButton @click="resetStockLogSearch">重置</ElButton>
</ElFormItem>
</ElForm>
<ElTable :data="stockLogList" :loading="stockLogLoading" max-height="400">
<ElTableColumn label="物品名称" prop="item_name" />
<ElTableColumn label="变化类型" prop="change_type_text" />
<ElTableColumn label="变化数量" prop="change_qty">
<template #default="scope">
<span :style="{ color: scope.row.change_qty > 0 ? '#67C23A' : '#F56C6C' }">
{{ scope.row.change_qty > 0 ? '+' : '' }}{{ scope.row.change_qty }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="变化前" prop="before_qty"/>
<ElTableColumn label="变化后" prop="after_qty" />
<ElTableColumn label="业务类型" prop="biz_type_text" />
<ElTableColumn label="操作人" prop="operator_name" />
<ElTableColumn label="备注" prop="remark" min-width="120" />
<ElTableColumn label="创建时间" prop="created_at"/>
</ElTable>
<!-- 分页 -->
<div style="margin-top: 16px; display: flex; justify-content: flex-end">
<ElPagination
v-model:current-page="stockLogPagination.currentPage"
v-model:page-size="stockLogPagination.pageSize"
:total="stockLogPagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleStockLogSizeChange"
@current-change="handleStockLogCurrentChange"
/>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="stockLogDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
<!-- 领取记录弹窗 -->
<ElDialog v-model="qrClaimsDialogVisible" title="领取记录" width="80%" align-center>
<!-- 搜索表单 -->
<ElForm :model="qrClaimsSearchForm" inline style="margin-bottom: 16px">
<ElFormItem label="领取方式">
<ElSelect
v-model="qrClaimsSearchForm.claim_type"
placeholder="请选择领取方式"
clearable
style="width: 150px"
>
<ElOption label="扫码领取" :value="2" />
<ElOption label="管理员代领" :value="3" />
</ElSelect>
</ElFormItem>
<ElFormItem label="开始时间">
<ElDatePicker
v-model="qrClaimsSearchForm.start_time"
type="datetime"
placeholder="请选择开始时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 180px"
/>
</ElFormItem>
<ElFormItem label="结束时间">
<ElDatePicker
v-model="qrClaimsSearchForm.end_time"
type="datetime"
placeholder="请选择结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 180px"
/>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="searchQrClaims">查询</ElButton>
<ElButton @click="resetQrClaimsSearch">重置</ElButton>
</ElFormItem>
</ElForm>
<ElTable :data="qrClaimsList" :loading="qrClaimsLoading" max-height="400">
<ElTableColumn label="物品">
<template #default="scope">
<span v-if="scope.row.items && scope.row.items.length > 0">
<template v-for="(item, index) in scope.row.items" :key="index">
{{ item.item_name_snap }}<span v-if="Number(index) < scope.row.items.length - 1">, </span>
</template>
</span>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="数量">
<template #default="scope">
<span v-if="scope.row.items && scope.row.items.length > 0">
<template v-for="(item, index) in scope.row.items" :key="index">
{{ item.approved_qty }}<span v-if="Number(index) < scope.row.items.length - 1">, </span>
</template>
</span>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="领取人" prop="applicant_name" />
<ElTableColumn label="领取类型" prop="claim_type_text" />
<ElTableColumn label="领取时间" prop="created_at" />
<ElTableColumn label="代领人" prop="proxy_claim_user_name">
<template #default="scope">
{{ scope.row.proxy_claim_user_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="领取方式" prop="borrow_reason" />
<ElTableColumn label="备注" prop="remark" />
</ElTable>
<!-- 分页 -->
<div style="margin-top: 16px; display: flex; justify-content: flex-end">
<ElPagination
v-model:current-page="qrClaimsPagination.currentPage"
v-model:page-size="qrClaimsPagination.pageSize"
:total="qrClaimsPagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleQrClaimsSizeChange"
@current-change="handleQrClaimsCurrentChange"
/>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="qrClaimsDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading, ArrowDown, Document, User, List, Tickets, Delete } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { invitemService } from '@/api/admin/Itemrequisition'
import { manageService } from '@/api/adminApi'
import { UserService } from '@/api/usersApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'ItemList' })
const keyword = ref<string>('')
const loading = ref(false)
const dialogVisible = ref(false)
const qrCodeDialogVisible = ref(false)
const qrCodeData = ref('')
const qrCodeLoading = ref(false)
const proxyClaimDialogVisible = ref(false)
const stockLogDialogVisible = ref(false)
const stockLogList = ref<any[]>([])
const stockLogLoading = ref(false)
const currentStockLogItemId = ref<number | null>(null)
const stockLogSearchForm = reactive({
change_type: undefined as number | undefined
})
const stockLogPagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const qrClaimsDialogVisible = ref(false)
const qrClaimsList = ref<any[]>([])
const qrClaimsLoading = ref(false)
const currentQrClaimsItemId = ref<number | null>(null)
const qrClaimsSearchForm = reactive({
claim_type: undefined as number | undefined,
start_time: '',
end_time: ''
})
const qrClaimsPagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const proxyClaimFormRef = ref<FormInstance>()
const formRef = ref<FormInstance>()
const rules = reactive<FormRules>({
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
item_name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
manage_user_id: [{ required: true, message: '请选择负责人', trigger: 'change' }]
})
const form = reactive({
company_id: '',
manage_user_id: '',
remark: '',
item_name: '',
stock_qty: 0,
allow_qr_claim: 0,
is_consumable: 0,
qr_code_num: 0,
status: 1,
id: ''
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const companyList = ref<any[]>([])
const getcompanyList = async () => {
const response = await UserService.getCompanyList()
const list = (response as any)?.data ?? (response as any)?.records ?? response
companyList.value = Array.isArray(list) ? list : []
}
const manageUserList = ref<any>()
// 获取用户列表
const getCompanyUserList = async () => {
const response = await manageService.presetusers()
manageUserList.value = response || []
}
// 管理员代领相关
const proxyClaimForm = reactive({
item_id: '',
target_user_ids: [] as number[]
})
const userList = ref<any[]>([])
// 获取所有用户列表
const getAllUserList = async () => {
try {
const response = await manageService.presetusers()
userList.value = response || []
} catch (error) {
console.error('获取用户列表失败:', error)
}
}
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
getcompanyList()
getCompanyUserList()
getAllUserList()
})
watch([keyword], () => {
pagination.currentPage = 1
getWorkList()
})
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (keyword.value) {
params.keyword = keyword.value
}
const { records, current, size, total } = await invitemService.getinvitemlist(params)
tableData.value = records.map((item: any, index: number) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
const dialogType = ref('add')
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
// 编辑模式,填充所有字段
Object.assign(form, {
company_id: row.company_id,
item_name: row.item_name,
manage_user_id: row.manage_user_id,
remark: row.remark,
stock_qty: row.stock_qty ?? 0,
is_consumable: row.is_consumable ?? 0,
allow_qr_claim: row.allow_qr_claim ?? 0,
qr_code_num: row.qr_code_num ?? 0,
status: row.status,
id: row.id
})
} else {
// 新增模式,重置所有字段
Object.assign(form, {
company_id: '',
item_name: '',
manage_user_id: '',
remark: '',
stock_qty: 0,
is_consumable: 0,
allow_qr_claim: 0,
qr_code_num: 0,
status: 1,
id: ''
})
}
}
const deleteRole = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await invitemService.invitemdelete({
id: row.id
})
ElMessage.success(message)
getWorkList()
})
}
// 删除二维码
const deleteQrCode = (row: any) => {
ElMessageBox.confirm('确定删除该物品的二维码吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const { message } = await invitemService.deleteQrCode({
id: row.id
})
ElMessage.success(message)
getWorkList()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (valid) {
if (dialogType.value === 'add') {
const { message } = await invitemService.invitemadd({
company_id: form.company_id,
item_name: form.item_name,
manage_user_id: form.manage_user_id,
remark: form.remark,
stock_qty: form.stock_qty,
is_consumable: form.is_consumable,
allow_qr_claim: form.allow_qr_claim,
qr_code_num: form.qr_code_num || 0,
status: form.status
})
ElMessage.success(message)
} else {
const { message } = await invitemService.invitemupdate({
id: form.id,
company_id: form.company_id,
item_name: form.item_name,
manage_user_id: form.manage_user_id,
remark: form.remark,
stock_qty: form.stock_qty,
is_consumable: form.is_consumable,
allow_qr_claim: form.allow_qr_claim,
qr_code_num: form.qr_code_num || 0,
status: form.status
})
ElMessage.success(message)
}
dialogVisible.value = false
getWorkList()
}
})
}
// 生成/查看二维码
const generateQrCode = async (row: any) => {
qrCodeDialogVisible.value = true
// 如果已有二维码URL,直接使用
if (row.qr_code_pull) {
qrCodeData.value = row.qr_code_pull
qrCodeLoading.value = false
return
}
// 没有二维码URL,调用接口生成
qrCodeLoading.value = true
qrCodeData.value = ''
try {
const response = await invitemService.getQrCode({ id: row.id })
qrCodeData.value = (response as any)?.data?.qr_code_url || ''
ElMessage.success('二维码生成成功')
// 刷新列表以更新 qr_code_pull
getWorkList()
} catch (error) {
console.error('生成二维码失败:', error)
ElMessage.error('生成二维码失败')
} finally {
qrCodeLoading.value = false
}
}
// 关闭二维码弹窗
const closeQrCodeDialog = () => {
qrCodeDialogVisible.value = false
qrCodeData.value = ''
qrCodeLoading.value = false
}
// 下载二维码
const downloadQrCode = () => {
if (!qrCodeData.value) {
ElMessage.warning('暂无二维码可下载')
return
}
const link = document.createElement('a')
link.href = qrCodeData.value
link.download = `item-qr-code-${Date.now()}.png`
link.click()
ElMessage.success('二维码下载成功')
}
// 显示管理员代领弹窗
const showProxyClaimDialog = (row: any) => {
proxyClaimDialogVisible.value = true
proxyClaimForm.item_id = row.id
proxyClaimForm.target_user_ids = []
}
// 提交管理员代领
const handleProxyClaimSubmit = async () => {
if (!proxyClaimForm.target_user_ids || proxyClaimForm.target_user_ids.length === 0) {
ElMessage.warning('请选择用户')
return
}
try {
const { message } = await invitemService.proxyClaim({
item_id: proxyClaimForm.item_id,
target_user_id: proxyClaimForm.target_user_ids.join(',')
})
ElMessage.success(message)
proxyClaimDialogVisible.value = false
getWorkList()
} catch (error) {
console.error('管理员代领失败:', error)
ElMessage.error('操作失败')
}
}
// 显示库存记录弹窗
const showStockLogDialog = async (row: any) => {
currentStockLogItemId.value = row.id
stockLogDialogVisible.value = true
// 重置搜索条件和分页
stockLogSearchForm.change_type = undefined
stockLogPagination.currentPage = 1
stockLogPagination.pageSize = 10
await getStockLogList()
}
// 获取库存记录列表
const getStockLogList = async () => {
if (!currentStockLogItemId.value) return
stockLogLoading.value = true
stockLogList.value = []
try {
const params: any = {
item_id: currentStockLogItemId.value,
current: stockLogPagination.currentPage,
size: stockLogPagination.pageSize
}
if (stockLogSearchForm.change_type) {
params.change_type = stockLogSearchForm.change_type
}
const response = await invitemService.getStockLogList(params)
const records = (response as any)?.data || (response as any)?.records || []
stockLogList.value = Array.isArray(records) ? records : []
stockLogPagination.total = (response as any)?.total || 0
} catch (error) {
console.error('获取库存记录失败:', error)
ElMessage.error('获取库存记录失败')
} finally {
stockLogLoading.value = false
}
}
// 搜索库存记录
const searchStockLog = () => {
stockLogPagination.currentPage = 1
getStockLogList()
}
// 重置搜索条件
const resetStockLogSearch = () => {
stockLogSearchForm.change_type = undefined
stockLogPagination.currentPage = 1
getStockLogList()
}
// 分页大小变化
const handleStockLogSizeChange = (newPageSize: number) => {
stockLogPagination.pageSize = newPageSize
getStockLogList()
}
// 当前页变化
const handleStockLogCurrentChange = (newCurrentPage: number) => {
stockLogPagination.currentPage = newCurrentPage
getStockLogList()
}
// 显示扫码记录弹窗
const showQrClaimsDialog = async (row: any) => {
currentQrClaimsItemId.value = row.id
qrClaimsDialogVisible.value = true
// 重置搜索条件和分页
qrClaimsSearchForm.claim_type = undefined
qrClaimsSearchForm.start_time = ''
qrClaimsSearchForm.end_time = ''
qrClaimsPagination.currentPage = 1
qrClaimsPagination.pageSize = 10
await getQrClaimsList()
}
// 获取扫码记录列表
const getQrClaimsList = async () => {
if (!currentQrClaimsItemId.value) return
qrClaimsLoading.value = true
qrClaimsList.value = []
try {
const params: any = {
item_id: currentQrClaimsItemId.value,
current: qrClaimsPagination.currentPage,
size: qrClaimsPagination.pageSize
}
if (qrClaimsSearchForm.claim_type) {
params.claim_type = qrClaimsSearchForm.claim_type
}
if (qrClaimsSearchForm.start_time) {
params.start_time = qrClaimsSearchForm.start_time
}
if (qrClaimsSearchForm.end_time) {
params.end_time = qrClaimsSearchForm.end_time
}
const response = await invitemService.getQrClaimsList(params)
const records = (response as any)?.data || (response as any)?.records || []
qrClaimsList.value = Array.isArray(records) ? records : []
qrClaimsPagination.total = (response as any)?.total || 0
} catch (error) {
console.error('获取扫码记录失败:', error)
ElMessage.error('获取扫码记录失败')
} finally {
qrClaimsLoading.value = false
}
}
// 搜索扫码记录
const searchQrClaims = () => {
qrClaimsPagination.currentPage = 1
getQrClaimsList()
}
// 重置搜索条件
const resetQrClaimsSearch = () => {
qrClaimsSearchForm.claim_type = undefined
qrClaimsSearchForm.start_time = ''
qrClaimsSearchForm.end_time = ''
qrClaimsPagination.currentPage = 1
getQrClaimsList()
}
// 分页大小变化
const handleQrClaimsSizeChange = (newPageSize: number) => {
qrClaimsPagination.pageSize = newPageSize
getQrClaimsList()
}
// 当前页变化
const handleQrClaimsCurrentChange = (newCurrentPage: number) => {
qrClaimsPagination.currentPage = newCurrentPage
getQrClaimsList()
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-search-group {
display: flex;
gap: 12px;
}
.qr-code-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.qr-code-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.qr-code-image {
width: 280px;
height: 280px;
object-fit: contain;
}
.qr-code-desc {
margin: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.qr-code-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--el-text-color-secondary);
p {
margin: 0;
}
}
</style>
@@ -0,0 +1,742 @@
<template>
<div class="return-approval-page">
<div class="return-toolbar boxshadow">
<div class="toolbar-right">
<el-select
v-model="searchForm.status"
placeholder="选择状态"
clearable
style="width: 150px; margin-right: 12px"
@change="handleSearch"
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="searchForm.keyword"
placeholder="请输入关键字"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
>
<template #suffix>
<el-icon class="clickable" @click="handleSearch"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="boxshadow">
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="归还单号" prop="order_no" min-width="160" />
<ElTableColumn label="申请人" prop="applicant_name" min-width="120" />
<ElTableColumn label="归还物品" prop="return_item" min-width="200">
<template #default="scope">
<span>{{ getReturnItemNames(scope.row.return_item) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="归还时间" prop="return_at" min-width="120" />
<ElTableColumn label="状态" prop="status" min-width="120">
<template #default="scope">
<ElTag :type="getStatusType(scope.row.status)">
{{ getStatusLabel(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" min-width="160" />
<ElTableColumn label="操作" fixed="right" min-width="180" align="center">
<template #default="scope">
<ElRow :gutter="10" justify="center">
<el-link
type="primary"
underline="never"
@click="handleViewDetail(scope.row)"
style="margin-right: 8px"
v-if="hasAuth('return_approval@show')"
>
<i class="iconfont-sys">&#xe76c;</i> 查看详情
</el-link>
<el-link
v-if="canApprove(scope.row) && hasAuth('return_approval@approve')"
type="success"
underline="never"
@click="handleApprove(scope.row)"
>
<i class="iconfont-sys">&#xe6bf;</i> 审批
</el-link>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="归还申请详情"
width="800px"
:close-on-click-modal="false"
>
<div v-if="detailData.id" class="detail-content">
<!-- 基本信息 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>基本信息</span>
</div>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>归还单号</label>
<span>{{ detailData.order_no }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>申请人</label>
<span>{{ detailData.applicant_name }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>归还时间</label>
<span>{{ detailData.return_at }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>状态</label>
<span>{{ getStatusLabel(detailData.status) }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row" v-if="detailData.apply_remark">
<ElCol :span="24">
<div class="info-item">
<label>申请备注</label>
<span>{{ detailData.apply_remark }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row" v-if="detailData.handle_remark">
<ElCol :span="24">
<div class="info-item">
<label>处理备注</label>
<span>{{ detailData.handle_remark }}</span>
</div>
</ElCol>
</ElRow>
</div>
<!-- 归还物品 -->
<div class="info-section" v-if="detailData.item_return && detailData.item_return.length > 0">
<div class="section-title">
<div class="title-bar"></div>
<span>归还物品</span>
</div>
<div class="item-list">
<div
v-for="(item, index) in detailData.item_return"
:key="index"
class="item-card"
>
<div class="item-row">
<span class="item-label">物品名称</span>
<span class="item-value">{{ item.requisition_item.item_name_snap }}</span>
</div>
<div class="item-row">
<span class="item-label">申请数量</span>
<span class="item-value">{{ item.requisition_item.request_qty }}</span>
</div>
<div class="item-row">
<span class="item-label">已归还数量</span>
<span class="item-value">{{ item.requisition_item.returned_qty }}</span>
</div>
<div class="item-row">
<span class="item-label">本次归还</span>
<span class="item-value">{{ item.return_qty }}</span>
</div>
</div>
</div>
</div>
<!-- 附件 -->
<div class="info-section" v-if="detailData.return_attachment && detailData.return_attachment.length > 0">
<div class="section-title">
<div class="title-bar"></div>
<span>附件</span>
</div>
<div class="image-attachment-list">
<div
v-for="(file, index) in detailData.return_attachment"
:key="index"
class="image-attachment-item"
>
<el-image
:src="file.file_path_pull"
:preview-src-list="getImagePreviewUrls(detailData.return_attachment)"
:initial-index="Number(index)"
fit="cover"
class="attachment-image"
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
<div class="image-name">{{ file.file_name }}</div>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- 审批弹窗 -->
<el-dialog
v-model="approvalDialogVisible"
title="归还申请审批"
width="800px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<div v-if="approvalDetail.id" class="approval-content">
<!-- 申请详情展示 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>申请信息</span>
</div>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>归还单号</label>
<span>{{ approvalDetail.order_no }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>申请人</label>
<span>{{ approvalDetail.applicant_name }}</span>
</div>
</ElCol>
</ElRow>
<ElRow :gutter="16" class="info-row">
<ElCol :span="12">
<div class="info-item">
<label>归还时间</label>
<span>{{ approvalDetail.return_at }}</span>
</div>
</ElCol>
<ElCol :span="12">
<div class="info-item">
<label>申请备注</label>
<span>{{ approvalDetail.apply_remark || '-' }}</span>
</div>
</ElCol>
</ElRow>
</div>
<!-- 归还物品 -->
<div class="info-section" v-if="approvalDetail.item_return && approvalDetail.item_return.length > 0">
<div class="section-title">
<div class="title-bar"></div>
<span>归还物品</span>
</div>
<div class="item-list">
<div
v-for="(item, index) in approvalDetail.item_return"
:key="index"
class="item-card"
>
<div class="item-row">
<span class="item-label">物品名称</span>
<span class="item-value">{{ item.requisition_item.item_name_snap }}</span>
</div>
<div class="item-row">
<span class="item-label">申请数量</span>
<span class="item-value">{{ item.requisition_item.request_qty }}</span>
</div>
<div class="item-row">
<span class="item-label">已归还数量</span>
<span class="item-value">{{ item.requisition_item.returned_qty }}</span>
</div>
<div class="item-row">
<span class="item-label">本次归还</span>
<span class="item-value">{{ item.return_qty }}</span>
</div>
</div>
</div>
</div>
<!-- 审批意见 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>审批意见</span>
</div>
<ElInput
v-model="approvalForm.comment"
type="textarea"
:rows="3"
placeholder="请输入审批意见"
/>
</div>
<!-- 上传附件 -->
<div class="info-section">
<div class="section-title">
<div class="title-bar"></div>
<span>附件(可选)</span>
</div>
<el-upload
v-model:file-list="approvalFileList"
:action="uploadAction"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
:on-remove="handleUploadRemove"
:limit="5"
multiple
>
<el-button type="primary">点击上传</el-button>
<template #tip>
<div class="el-upload__tip">支持图片、PDF等格式,最多上传5个文件</div>
</template>
</el-upload>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton
type="danger"
plain
size="large"
class="submit-btn"
@click="handleSubmitApprove('reject')"
>
拒绝
</ElButton>
<ElButton
type="primary"
size="large"
class="submit-btn"
@click="handleSubmitApprove('approve')"
>
同意
</ElButton>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadUserFile, UploadProps } from 'element-plus'
import { invitemService } from '@/api/admin/Itemrequisition'
import { useAuth } from '@/composables/useAuth'
import { useUploadHeaders } from '@/composables/useUploadHeaders'
defineOptions({ name: 'ReturnApprovalPage' })
const { hasAuth } = useAuth()
const { uploadHeaders } = useUploadHeaders()
// 上传地址
const uploadAction = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=return_approval`
// 状态选项
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '已通过', value: 1 },
{ label: '未通过', value: 2 }
]
// 获取状态标签颜色
const getStatusType = (status: number): 'success' | 'danger' | 'info' | 'warning' | 'primary' => {
const typeMap: Record<number, 'success' | 'danger' | 'info' | 'warning' | 'primary'> = {
0: 'warning', // 待审核
1: 'success', // 已通过
2: 'danger' // 未通过
}
return typeMap[status] || 'info'
}
// 获取状态文本
const getStatusLabel = (status: number) => {
const labelMap: Record<number, string> = {
0: '待审核',
1: '已通过',
2: '未通过'
}
return labelMap[status] || '未知'
}
// 判断是否可以审批
const canApprove = (row: any) => {
return row.status === 0
}
// 获取归还物品名称列表
const getReturnItemNames = (returnItems: any[]) => {
if (!returnItems || returnItems.length === 0) return '-'
const names = returnItems
.map((item) => {
if (item.item && item.item.length > 0) {
return item.item[0].item_name
}
return ''
})
.filter(Boolean)
return names.join('、')
}
// 获取图片预览URL列表
const getImagePreviewUrls = (attachments: any[]) => {
if (!attachments || attachments.length === 0) return []
return attachments.map((item) => item.file_path_pull)
}
const loading = ref(false)
const tableRef = ref()
const tableData = ref<any[]>([])
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined
})
// 详情弹窗数据
const detailDialogVisible = ref(false)
const detailData = ref<any>({})
// 审批弹窗数据
const approvalDialogVisible = ref(false)
const currentRowId = ref<number | null>(null)
const approvalDetail = ref<any>({})
const approvalForm = reactive({
comment: '',
attachment: [] as any[]
})
const approvalFileList = ref<UploadUserFile[]>([])
// 上传成功处理
const handleUploadSuccess: UploadProps['onSuccess'] = (response) => {
if (response.code === 200) {
approvalForm.attachment.push({
original_name: response.data.original_name || '文件',
file_path: response.data.file_path || response.data.file_url,
file_url: response.data.file_url
})
ElMessage.success('文件上传成功')
}
}
// 删除文件处理
const handleUploadRemove: UploadProps['onRemove'] = (file) => {
const index = approvalForm.attachment.findIndex(
(item) => item.file_url === file.url || item.original_name === file.name
)
if (index > -1) {
approvalForm.attachment.splice(index, 1)
}
}
onMounted(() => {
getList()
})
// 获取列表数据
const getList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (searchForm.keyword) {
params.keyword = searchForm.keyword
}
if (searchForm.status !== undefined) {
params.status = searchForm.status
}
const { records, current, size, total } = await invitemService.getReturnList(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.currentPage = 1
getList()
}
// 分页变化
const handleSizeChange = (size: number) => {
pagination.pageSize = size
getList()
}
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
getList()
}
// 查看详情
const handleViewDetail = async (row: any) => {
try {
const detail = await invitemService.getReturnDetail({ id: row.id })
detailData.value = detail
detailDialogVisible.value = true
} catch (error) {
console.error('获取详情失败:', error)
}
}
// 打开审批弹窗 - 获取完整详情
const handleApprove = async (row: any) => {
try {
const detail = await invitemService.getReturnDetail({ id: row.id })
currentRowId.value = row.id
approvalDetail.value = detail
approvalForm.comment = ''
approvalForm.attachment = []
approvalFileList.value = []
approvalDialogVisible.value = true
} catch (error) {
console.error('获取详情失败:', error)
}
}
// 提交审批
const handleSubmitApprove = async (action: 'approve' | 'reject') => {
if (!approvalForm.comment) {
ElMessage.warning('请输入审批意见')
return
}
try {
const { message } = await invitemService.returnconfirm({
id: currentRowId.value!,
action,
comment: approvalForm.comment,
attachment: approvalForm.attachment.map((item) => ({
original_name: item.original_name,
file_path: item.file_path
}))
})
ElMessage.success(message)
approvalDialogVisible.value = false
approvalForm.comment = ''
approvalForm.attachment = []
currentRowId.value = null
approvalDetail.value = {}
approvalFileList.value = []
getList()
} catch (error) {
console.error('审批失败:', error)
}
}
</script>
<style scoped lang="scss">
.return-approval-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.return-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--art-main-bg-color);
.toolbar-right {
display: flex;
align-items: center;
}
}
.clickable {
cursor: pointer;
}
// 详情和审批弹窗样式
.detail-content,
.approval-content {
max-height: 60vh;
overflow-y: auto;
}
.info-section {
margin: 20px 0;
.section-title {
display: flex;
align-items: center;
margin-bottom: 16px;
.title-bar {
width: 4px;
height: 20px;
background: #409eff;
margin-right: 8px;
border-radius: 2px;
}
span {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
}
.info-row {
margin-bottom: 12px;
}
.info-item {
display: flex;
flex-direction: column;
label {
font-size: 13px;
color: #909399;
margin-bottom: 6px;
}
span {
font-size: 14px;
color: #303133;
}
}
// 物品列表
.item-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.item-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
background: #fafafa;
.item-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
&:last-child {
margin-bottom: 0;
}
.item-label {
color: #909399;
}
.item-value {
color: #303133;
font-weight: 500;
}
}
}
// 图片附件列表
.image-attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.image-attachment-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100px;
}
.attachment-image {
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px solid #e4e7ed;
overflow: hidden;
background: #f5f7fa;
:deep(.el-image__inner) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-error {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 32px;
}
.image-name {
font-size: 12px;
color: #606266;
text-align: center;
word-break: break-all;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 弹窗底部按钮
.dialog-footer {
display: flex;
justify-content: center;
gap: 20px;
.submit-btn {
min-width: 120px;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
<template>
<div class="color-page">
<div class="color-toolbar boxshadow">
<div class="color-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input
style="min-width: 120px"
v-model="keyword"
placeholder="请输入关键字"
clearable
@keyup.enter="getColorList"
/>
</el-form-item>
</div>
</div>
<div class="boxshadow">
<ElRow>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton @click="addfn()" v-ripple v-if="hasAuth('approval_list@add')" type="primary">
新增审核流程
</ElButton>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="名称" prop="name" />
<ElTableColumn label="标识" prop="type" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.is_active ? 'success' : 'info'">
{{ scope.row.is_active ? '启用' : '停用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" />
<ElTableColumn fixed="right" label="操作" width="140px">
<template #default="scope">
<ElRow>
<el-button
link
@click="showDialog('edit', scope.row)"
v-if="hasAuth('approval_list@edit')"
>编辑</el-button
>
<el-button
type="danger"
link
@click="deleteColor(scope.row)"
v-if="hasAuth('approval_list@destroy')"
>
删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 新增/编辑对话框 -->
<ElDialog v-model="dialogVisible" title="新增审批流程" align-center width="600px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px" class="form">
<ElFormItem label="名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入名称" />
</ElFormItem>
<ElFormItem label="标识" prop="type">
<ElInput v-model="form.type" placeholder="请输入标识" />
</ElFormItem>
<ElFormItem label="超时时间" prop="timeout_hours">
<ElInput v-model="form.timeout_hours" placeholder="请输入超时时间" >
<template #suffix>
<span>h</span>
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="超时动作" prop="timeout_action">
<el-select v-model="form.timeout_action" placeholder="请选择颜色类型" style="width: 100%">
<el-option label="外观颜色" :value="1" />
<el-option label="内饰颜色" :value="2" />
</el-select>
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.is_active" :active-value="1" :inactive-value="0" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import { router } from '@/router'
const { hasAuth } = useAuth()
defineOptions({ name: 'AdminApprovalList' })
// 响应式数据
const loading = ref(false)
const dialogVisible = ref(false)
const dialogType = ref('add')
const formRef = ref<FormInstance>()
const keyword = ref<string>('')
const typeFilter = ref<number | undefined>(undefined)
const statusFilter = ref<number | undefined>(undefined)
// 表单数据
const form = reactive<any>({
name: '',
type: '',
timeout_hours: '',
timeout_action: '',
is_active: 1
})
// 表单验证规则
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
})
// 分页数据
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
const tableRef = ref()
// 生命周期
onMounted(() => {
getColorList()
})
// 监听搜索条件变化
watch([keyword, typeFilter, statusFilter], () => {
pagination.currentPage = 1
getColorList()
})
// 新增按钮点击
const addfn = () => {
router.push('/admin/approval/approval_add')
}
// 获取颜色列表
const getColorList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params = {
current: currentPage,
size: pageSize,
keyword: keyword.value
}
const { records, current, size, total } = await manageService.getstocklist(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取颜色列表失败:', error)
ElMessage.error('获取颜色列表失败')
} finally {
loading.value = false
}
}
// 重置搜索条件
const resetSearch = () => {
keyword.value = ''
typeFilter.value = undefined
statusFilter.value = undefined
pagination.currentPage = 1
getColorList()
}
// 处理分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getColorList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getColorList()
}
// 显示对话框
const showDialog = (type: string, row?: any) => {
router.push(`/admin/approval/approval_edit?id=${row.id}`)
}
// 删除
const deleteColor = (row: any) => {
ElMessageBox.confirm('确定删除该审核流程吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
try {
const { message } = await manageService.stockapprovaldelete({ id: row.id })
ElMessage.success(message)
getColorList()
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
})
}
// 提交表单
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (!valid) return
try {
// 新增
const { message } = await manageService.stockapproval(form)
ElMessage.success(message)
dialogVisible.value = false
getColorList()
} catch (error) {
console.error('操作失败:', error)
ElMessage.error('操作失败')
}
})
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.color-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.color-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.color-search-group {
display: flex;
gap: 12px;
align-items: center;
}
.form {
padding: 20px 0;
}
.dialog-footer {
text-align: right;
}
.el-col2 {
margin-bottom: 16px;
}
</style>
+551
View File
@@ -0,0 +1,551 @@
<template>
<div class="stocking-page">
<div class="stocking-toolbar boxshadow">
<div class="toolbar-right">
<el-input
v-model="keyword"
placeholder="请输入客户名称"
clearable
style="width: 260px"
@keyup.enter="getList"
>
<template #suffix>
<el-icon class="clickable" @click="getList"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="boxshadow">
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="审批单名称" prop="flows_name" min-width="140">
<template #default="scope">
<span :style="{ color: getFlowTypeColor(scope.row.flow_type) }">
{{ scope.row.flows_name }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="审批说明" prop="biz_desc" min-width="160" />
<ElTableColumn label="提交人" prop="initiator_name" min-width="120">
<template #default="scope">
<div style="display: flex; align-items: center">
<img class="avatar" :src="scope.row.initiator_avatar" alt="" />
<p>{{ scope.row.initiator_name }}</p>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="审核人" prop="approver_name" min-width="120" />
<ElTableColumn label="状态" prop="status_desc" min-width="120" />
<ElTableColumn label="提交时间" prop="created_at" min-width="120" />
<ElTableColumn label="操作" fixed="right" min-width="140" align="center">
<template #default="scope">
<ElRow :gutter="10" justify="center">
<el-link
type="primary"
underline="never"
@click="handleView(scope.row)"
style="margin-right: 8px"
v-if="hasAuth('stocking_auth@show')"
>
<i class="iconfont-sys">&#xe76c;</i> 查看审批
</el-link>
<el-link
v-if="scope.row.status == 'pending' && hasAuth('stocking_auth@approve')"
type="success"
underline="never"
@click="handleCreateRepair(scope.row)"
>
<i class="iconfont-sys">&#xe6bf;</i> 快速审批
</el-link>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 快速审批弹出框 -->
<el-dialog
v-model="approvalDialogVisible"
:title="orderDialog.title"
width="80%"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="approval-dialog"
>
<div class="approval-content">
<!-- 头部信息 -->
<div
v-if="orderDialog.type == 'stock_orders' || orderDialog.type == 'stock_orders_nullify'"
>
<OrderDetailContent :orderData="orderDialog.orderData" />
</div>
<div v-if="orderDialog.type == 'purchase_orders'">
<ProcurementDetailView :detailData="orderDialog.orderData" />
</div>
<!-- 审批备注 -->
<div class="info-section">
<ElRow :gutter="12">
<ElCol :span="24">
<el-input
v-model="approvalNotes"
type="textarea"
:rows="2"
placeholder="备注"
class="approval-notes"
/>
</ElCol>
</ElRow>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton
type="primary"
plain
size="large"
class="submit-btn"
@click="handleApprove('reject')"
>驳回</ElButton
>
<ElButton type="primary" size="large" class="submit-btn" @click="handleApprove('approve')"
>同意</ElButton
>
<ElButton
v-if="orderDialog.type == 'purchase_orders'"
type="warning"
size="large"
class="submit-btn"
@click="handleTransfer()"
>转交</ElButton
>
</div>
</template>
</el-dialog>
<!-- 转交弹出框 -->
<el-dialog v-model="zjDialogVisible" title="转交" width="450px">
<el-form :model="zjForm" label-width="80px">
<el-form-item label="转交人" prop="to_uid">
<el-select
v-model="zjForm.to_uid"
filterable
placeholder="请选择转交人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<ElFormItem label="转交原因" prop="reason">
<ElInput v-model="zjForm.reason" type="textarea" :rows="3" placeholder="请输入转交原因" />
</ElFormItem>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="zjDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handlezjSubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { router } from '@/router'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import { commonService } from '@/api/commonApi'
import OrderDetailContent from '@/components/custom/OrderDetailContent.vue'
import type { OrderData } from '@/composables/useOrderDetailDialog'
import ProcurementDetailView from '@/components/custom/ProcurementDetailView.vue'
defineOptions({ name: 'AdminStockingPage' })
const zjDialogVisible = ref(false)
const orderDialog = reactive({
orderData: {} as OrderData,
title: '',
type: ''
})
const { hasAuth } = useAuth()
const loading = ref(false)
const keyword = ref('')
const tableRef = ref()
const tableData = ref<any[]>([])
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 根据 flow_type 获取审批单名称的字体颜色
const getFlowTypeColor = (flowType: string): string => {
switch (flowType) {
case 'stock_orders':
return '#409EFF' // 蓝色 - 备货单
case 'stock_orders_nullify':
return '#F56C6C' // 红色 - 备货单作废
case 'purchase_orders':
return '#67C23A' // 绿色 - 采购单
case 'purchase_orders_nullify':
return '#E6A23C' // 橙色 - 采购单作废
case 'return_orders':
return '#909399' // 灰色 - 退货单
case 'refund_orders':
return '#9C27B0' // 紫色 - 退款单
default:
return '#303133' // 默认黑色
}
}
const zjForm = reactive({
to_uid: '',
instance_id: '',
step_index: '',
reason: ''
})
// 审批弹出框相关
const approvalDialogVisible = ref(false)
const approvalNotes = ref('')
onMounted(() => {
getList()
})
watch([keyword], () => {
pagination.currentPage = 1
// 不自动请求,保持与截图行为一致,点击搜索或回车触发
})
const getList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params = {
current: currentPage,
size: pageSize,
scene: 'todo',
keyword: keyword.value
}
const { records, current, size, total } = await manageService.stockapprovalrecord(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
getList()
}
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
getList()
}
const handleView = (row: any) => {
router.push({
path: '/admin/approval/stocking_auth_detil',
query: {
id: row.id,
biz_id: row.biz_id,
instance_id: row.instance_id,
status: row.status,
flow_type: row.flow_type,
step_index: row.step_index
} // 可带客户名等参数
})
}
const instance_id = ref('')
function extractFileName(pathOrUrl: string | undefined | null): string {
if (!pathOrUrl) return '合同文件'
try {
const clean = String(pathOrUrl)
const afterSlash = clean.split(/[\\/]/).pop() || '合同文件'
return decodeURIComponent(afterSlash)
} catch {
return '合同文件'
}
}
const activerow = ref()
const handleCreateRepair = async (row: any) => {
activerow.value = row
instance_id.value = row.instance_id
const res = await manageService.stockapprovaldetailborder({
id: row.id,
flow_type: row.flow_type
})
console.log(res)
switch (row.flow_type) {
case 'stock_orders': {
const orderData: OrderData = res
orderDialog.orderData = orderData
orderDialog.type = row.flow_type
orderDialog.title = '合塑实业-汽车事业部-备货单(客户下定)'
break
}
case 'stock_orders_nullify': {
const orderData: OrderData = res
orderDialog.orderData = orderData
orderDialog.type = row.flow_type
orderDialog.title = '备货单-作废'
break
}
case 'purchase_orders': {
const orderData: OrderData = res
const urls = res.contract_file_url
const paths = res.contract_file
const list: Array<{ name: string; url: string }> = []
const nameFromPath = (p: string) => extractFileName(p)
urls.forEach((u: string, i: number) => {
const p = paths[i] || ''
list.push({ name: p ? nameFromPath(p) : extractFileName(u), url: u })
})
orderData.contract_file_url = list
orderDialog.orderData = orderData
orderDialog.type = row.flow_type
orderDialog.title = '合塑实业-汽车事业部-采购单(采购)'
break
}
}
approvalDialogVisible.value = true
}
// 审批操作
const handleApprove = async (approvalAction: string) => {
try {
// 这里调用审批API
const { message } = await manageService.stockapprovalpass({
instance_id: instance_id.value,
action: approvalAction,
comment: approvalNotes.value
})
ElMessage.success(message)
approvalDialogVisible.value = false
approvalNotes.value = ''
instance_id.value = ''
// 刷新列表
getList()
} catch (error) {
console.error('审批失败:', error)
ElMessage.error('审批失败')
}
}
// 转交操作
const handleTransfer = async () => {
zjForm.instance_id = activerow.value.instance_id
zjForm.step_index = activerow.value.step_index
getuserList()
zjDialogVisible.value = true
}
const handlezjSubmit = async () => {
if (!zjForm.to_uid) {
ElMessage.warning('请选择转交人')
return
}
if (!zjForm.reason) {
ElMessage.warning('请输入转交原因')
return
}
try {
const { message } = await manageService.stockapprovaltransfer(zjForm)
ElMessage.success(message)
zjDialogVisible.value = false
approvalDialogVisible.value = false
approvalNotes.value = ''
zjForm.to_uid = ''
zjForm.reason = ''
instance_id.value = ''
// 刷新列表
getList()
} catch (error) {
console.error('转交失败:', error)
// ElMessage.error('转交失败')
}
}
</script>
<style scoped lang="scss">
.avatar {
width: 40px;
height: 40px;
border-radius: 6px;
margin-right: 10px;
}
.stocking-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.stocking-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--art-main-bg-color);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-right {
display: flex;
align-items: center;
}
.clickable {
cursor: pointer;
}
// 审批弹出框样式
.approval-dialog {
.approval-content {
max-height: 70vh;
overflow-y: auto;
}
.header {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 12px;
color: #333333;
.mr40 { margin-right: 40px; }
}
.info-section {
margin: 24px 0px;
.section-title {
display: flex;
align-items: center;
margin-bottom: 16px;
.title-bar {
width: 4px;
height: 20px;
background: #409eff;
margin-right: 8px;
border-radius: 2px;
}
span {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
}
.order-items {
.order-item {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: #fafafa;
.item-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e4e7ed;
.item-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.item-details {
.detail-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 12px;
.detail-item {
display: flex;
flex-direction: column;
label {
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: #606266;
}
}
}
}
}
}
.summary-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f0f9ff;
border-radius: 8px;
margin-bottom: 24px;
.summary-item {
.summary-label {
font-weight: 600;
color: #606266;
margin-right: 8px;
}
.summary-value {
font-size: 18px;
font-weight: 700;
color: #409eff;
}
}
}
.approval-notes {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: space-around;
width: 500px;
margin: auto;
gap: 12px;
.reject-btn {
border-color: #409eff;
color: #409eff;
background: white;
&:hover {
background: #ecf5ff;
}
}
.approve-btn {
background: #409eff;
border-color: #409eff;
}
}
}
</style>
@@ -0,0 +1,653 @@
<template>
<div class="stocking-show-page">
<div class="boxshadow">
<div v-if="flow_type == 'stock_orders' || flow_type == 'stock_orders_nullify'">
<OrderDetailContent :orderData="orderDialog.orderData" />
</div>
<div v-if="flow_type == 'purchase_orders'">
<ProcurementDetailView :detailData="orderDialog.orderData" />
</div>
</div>
<!-- 上传合同 -->
<div class="boxshadow">
<FilePreview title="合同文件" :files="contractFiles" empty-text="暂无合同文件" />
</div>
<!-- PI文件 -->
<div class="boxshadow">
<FilePreview title="PI文件" :files="piFiles" empty-text="暂无PI文件" />
</div>
<!-- 水单文件 -->
<div class="boxshadow">
<FilePreview title="水单文件" :files="waterBillFiles" empty-text="暂无水单文件" />
</div>
<div class="boxshadow">
<div class="form-title">
<span class="border"></span>
<span class="title">审批记录</span>
</div>
<div class="approval-timeline">
<div v-for="(rec, idx) in approvalRecords" :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 || rec.type || '审批人' }})</span>
</div>
<div class="row time">{{ rec.time }}</div>
</div>
</div>
<div v-if="!approvalRecords.length" class="empty">暂无审批记录</div>
</div>
</div>
<div class="boxshadow" v-if="status == 'pending'">
<div class="form-title">
<span class="border"></span>
<span class="title">审批</span>
</div>
<div class="">
<ElRow :gutter="12">
<ElCol :span="24">
<ElFormItem label="备注:">
<el-input
v-model="approvalNotes"
type="textarea"
:rows="2"
placeholder="请输入备注信息"
class="approval-notes"
/>
</ElFormItem>
</ElCol>
</ElRow>
</div>
</div>
<div class="form-card" v-if="status == 'pending'">
<!-- 底部信息 -->
<div class="submit-wrap">
<ElButton
type="primary"
plain
size="large"
class="submit-btn"
@click="handleSubmit('reject')"
>驳回</ElButton
>
<ElButton type="primary" size="large" class="submit-btn" @click="handleSubmit('approve')"
>同意</ElButton
>
<ElButton
v-if="flow_type == 'purchase_orders'"
type="warning"
size="large"
class="submit-btn"
@click="handleTransfer()"
>转交</ElButton
>
</div>
</div>
<!-- PDF 预览弹窗 -->
<el-dialog
v-model="pdf.visible"
title="打印预览"
width="80%"
top="40px"
:close-on-click-modal="false"
>
<div style="height: 70vh; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; justify-content: flex-end; gap: 8px">
<!-- <el-button type="primary" @click="print()">打印</el-button> -->
<el-button @click="download">下载</el-button>
</div>
<iframe
v-if="pdf.blobUrl || pdf.url"
ref="frameRef"
:src="pdf.blobUrl || pdf.url"
style="flex: 1; width: 100%; border: 1px solid #eee; border-radius: 6px"
/>
</div>
</el-dialog>
<!-- 转交弹出框 -->
<el-dialog v-model="zjDialogVisible" title="转交" width="450px">
<el-form :model="zjForm" label-width="80px">
<el-form-item label="转交人" prop="to_uid">
<el-select
v-model="zjForm.to_uid"
filterable
placeholder="请选择转交人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<ElFormItem label="转交原因" prop="reason">
<ElInput v-model="zjForm.reason" type="textarea" :rows="3" placeholder="请输入转交原因" />
</ElFormItem>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="zjDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handlezjSubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { commonService } from '@/api/commonApi'
import { manageService } from '@/api/adminApi'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { usePdfPreview } from '@/composables/usePdfPreview'
import OrderDetailContent from '@/components/custom/OrderDetailContent.vue'
import ProcurementDetailView from '@/components/custom/ProcurementDetailView.vue'
import FilePreview from '@/components/custom/FilePreview.vue'
import type { OrderData } from '@/composables/useOrderDetailDialog'
import { router } from '@/router'
defineOptions({ name: 'AdminStockingAuthDetil' })
const { pdf, frameRef, download } = usePdfPreview()
const zjDialogVisible = ref(false)
const route = useRoute()
const approval_id = route.query.id
const stocking_id = route.query.biz_id
const instance_id = route.query.instance_id
const step_index = route.query.step_index
const status = route.query.status
const flow_type = route.query.flow_type
const approvalNotes = ref('')
const orderDialog = reactive({
orderData: {} as OrderData
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
const zjForm = reactive({
to_uid: '',
instance_id: '',
step_index: '',
reason: ''
})
// 转交操作
const handleTransfer = async () => {
getuserList()
zjDialogVisible.value = true
}
const handlezjSubmit = async () => {
if (!zjForm.to_uid) {
ElMessage.warning('请选择转交人')
return
}
if (!zjForm.reason) {
ElMessage.warning('请输入转交原因')
return
}
try {
const zjForms = {
to_uid: zjForm.to_uid,
instance_id: instance_id,
step_index: step_index,
reason: zjForm.reason
}
const { message } = await manageService.stockapprovaltransfer(zjForms)
ElMessage.success(message)
router.push('/admin/approval/stocking_auth')
} catch (error) {
console.error('转交失败:', error)
// ElMessage.error('转交失败')
}
}
const handleSubmit = async (approvalAction: string) => {
try {
const { message } = await manageService.stockapprovalpass({
instance_id: instance_id,
action: approvalAction,
comment: approvalNotes.value
})
router.push('/admin/approval/stocking_auth')
ElMessage.success(message)
} catch {
ElMessage.error('提交失败')
}
}
// 审批记录(先填充假数据展示)
const approvalRecords = ref<any[]>([])
const getDictItemSelectlist = ref<any[]>([])
const getDictItemSelect = async () => {
const res = await commonService.getDictItemSelect({ dict_id: '18' })
getDictItemSelectlist.value = res
}
// 合同文件
// 文件数据
const contractFiles = ref<Array<{ name: string; url: string; file_name: string }>>([])
const piFiles = ref<Array<{ name: string; url: string; file_name: string }>>([])
const waterBillFiles = ref<Array<{ name: string; url: string; file_name: string }>>([])
// 处理文件数据
const processFiles = (files: any[], type: string) => {
if (!files || !Array.isArray(files)) return []
return files.map((file: any) => ({
name: file.original_name,
url: file.file_full_url || file.file_url,
file_name: file.file_url
}))
}
function extractFileName(pathOrUrl: string | undefined | null): string {
if (!pathOrUrl) return '合同文件'
try {
const clean = String(pathOrUrl)
const afterSlash = clean.split(/[\\/]/).pop() || '合同文件'
return decodeURIComponent(afterSlash)
} catch {
return '合同文件'
}
}
//获取详情并映射到回显模型
const getdetilfn = async () => {
const res = await manageService.stockapprovaldetailborder({
id: approval_id,
flow_type: flow_type
})
if (flow_type == 'stock_orders' || flow_type == 'stock_orders_nullify') {
const orderData: OrderData = res
orderDialog.orderData = orderData
} else if (flow_type == 'purchase_orders') {
const orderData: OrderData = res
const urls = res.contract_file_url
const paths = res.contract_file
const list: Array<{ name: string; url: string }> = []
const nameFromPath = (p: string) => extractFileName(p)
urls.forEach((u: string, i: number) => {
const p = paths[i] || ''
list.push({ name: p ? nameFromPath(p) : extractFileName(u), url: u })
})
orderData.contract_file_url = list
orderDialog.orderData = orderData
}
// 处理合同文件
const file_list = res?.file_list
if (file_list?.contract) {
contractFiles.value = processFiles(file_list.contract, '合同')
} else {
contractFiles.value = []
}
// 处理PI文件
if (file_list?.pi_doc) {
piFiles.value = processFiles(file_list.pi_doc, 'PI')
} else {
piFiles.value = []
}
// 处理水单文件
if (file_list?.transaction_slip) {
waterBillFiles.value = processFiles(file_list.transaction_slip, '水单')
} else {
waterBillFiles.value = []
}
// 审批记录(兼容多种字段)
const resstockapprovallogs = await manageService.stockapprovallogs({ instance_id: instance_id })
console.log(resstockapprovallogs)
const historyList = resstockapprovallogs
if (historyList.length > 0) {
approvalRecords.value = historyList.map((h: any) => ({
name: h?.initiator_name ? h?.initiator_name : h?.name,
role: h?.index == -1 ? '申请人' : '审批人',
time: h?.finished_at || '',
approved: h?.status === 'approved' || h?.status === 'submitted',
rejected: h?.status === 'rejected',
pending: h?.status != 'approved' && h?.status != 'rejected' && h?.status != 'submitted'
}))
}
}
// 判断是否为图片文件
const isImageFile = (file: { name: string; url: string; file_name: string }) => {
// 检查文件名
if (file.name && /\.(jpg|jpeg|png|gif|webp)$/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)$/i.test(urlWithoutParams)) {
return true
}
}
return false
}
// 预览图片
const previewImage = (file: { name: string; url: string; file_name: string }) => {
// 创建图片预览弹窗
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 = (file: { name: string; url: string; file_name: string }) => {
try {
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)
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败,请稍后重试')
}
}
onMounted(() => {
getDictItemSelect()
getdetilfn()
})
</script>
<style scoped lang="scss">
.stocking-show-page {
min-height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
margin-bottom: 12px;
color: #333333;
.header-left {
flex: 1;
text-align: center;
}
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 15px;
}
.sub {
color: #333333;
}
.mr40 {
margin-right: 40px;
}
}
.form-title {
font-weight: bold;
color: #222;
margin-bottom: 15px;
}
.form-title .title {
margin-left: 6px;
}
.items-wrap {
margin-top: 6px;
}
.item-row {
padding: 8px 0;
}
:deep(.el-input.is-disabled .el-input__inner) {
color: var(--el-text-color-regular);
}
.file-preview {
padding: 20px;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.contract-file {
width: 100%;
max-width: 400px;
}
.image-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.preview-image {
max-width: 200px;
max-height: 200px;
object-fit: contain;
border: 1px solid #e4e7ed;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s ease;
}
.preview-image:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.document-preview {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fafafa;
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: #409eff;
border-radius: 8px;
color: white;
font-size: 24px;
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.file-name {
font-size: 14px;
color: #333;
word-break: break-all;
}
.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;
}
.submit-wrap {
display: flex;
justify-content: space-around;
padding: 24px 0 8px;
width: 500px;
margin: auto;
}
/* 审批记录 时间线 */
.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;
// width: 14px;
// height: 14px;
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;
}
.name {
font-weight: 600;
}
.role {
color: #666;
margin-left: 4px;
}
.time {
color: #888;
font-size: 12px;
margin-top: 2px;
}
.empty {
color: #999;
padding: 8px 0 4px 4px;
}
</style>
@@ -0,0 +1,431 @@
<template>
<div class="stocking-page">
<div class="stocking-toolbar boxshadow">
<!-- <div class="toolbar-left">
<ElButton type="primary" v-ripple @click="handleCreate">创建备货单</ElButton>
</div> -->
<div class="toolbar-right">
<el-input
v-model="keyword"
placeholder="请输入客户名称"
clearable
style="width: 260px"
@keyup.enter="getList"
>
<template #suffix>
<el-icon class="clickable" @click="getList"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="boxshadow">
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="审批单名称" prop="flows_name" min-width="140">
<template #default="scope">
<span :style="{ color: getFlowTypeColor(scope.row.flow_type) }">
{{ scope.row.flows_name }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="审批说明" prop="biz_desc" min-width="160" />
<ElTableColumn label="审批结果" prop="action_desc" min-width="160" />
<ElTableColumn label="提交人" prop="initiator_name" min-width="120">
<template #default="scope">
<div style="display: flex; align-items: center">
<img class="avatar" :src="scope.row.initiator_avatar" alt="" />
<p>{{ scope.row.initiator_name }}</p>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="审核人" prop="approver_name" min-width="120" />
<ElTableColumn label="状态" prop="status_desc" min-width="120" />
<ElTableColumn label="审批时间" prop="approved_at" min-width="120" />
<ElTableColumn label="提交时间" prop="created_at" min-width="120" />
<ElTableColumn label="操作" fixed="right" min-width="140" align="center">
<template #default="scope">
<ElRow :gutter="10" justify="center">
<el-link
type="primary"
underline="never"
@click="handleView(scope.row)"
style="margin-right: 8px"
v-if="hasAuth('stocking_auth_history@show')"
>
<i class="iconfont-sys">&#xe76c;</i> 查看审批
</el-link>
<el-link
v-if="scope.row.status == 'pending' && hasAuth('stocking_auth_history@approve')"
type="success"
underline="never"
@click="handleCreateRepair(scope.row)"
>
<i class="iconfont-sys">&#xe6bf;</i> 快速审批
</el-link>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 快速审批弹出框 -->
<el-dialog
v-model="approvalDialogVisible"
title="合塑实业-汽车事业部-备货单(客户下定)"
width="80%"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="approval-dialog"
>
<div class="approval-content">
<!-- 头部信息 -->
<OrderDetailContent :orderData="orderDialog.orderData" />
<!-- 审批备注 -->
<div class="info-section">
<ElRow :gutter="12">
<ElCol :span="24">
<el-input
v-model="approvalNotes"
type="textarea"
:rows="2"
placeholder="备注"
class="approval-notes"
/>
</ElCol>
</ElRow>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" plain size="large" class="submit-btn" @click="handleApprove('reject')"
>驳回</ElButton
>
<ElButton type="primary" size="large" class="submit-btn" @click="handleApprove('approve')"
>同意</ElButton
>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { router } from '@/router'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import { CrmService } from '@/api/crmApi'
import { commonService } from '@/api/commonApi'
import OrderDetailContent from '@/components/custom/OrderDetailContent.vue'
import type { OrderData } from '@/composables/useOrderDetailDialog'
defineOptions({ name: 'AdminStockingPage' })
const orderDialog = reactive({
orderData: {} as OrderData
})
const { hasAuth } = useAuth()
const loading = ref(false)
const keyword = ref('')
const tableRef = ref()
const tableData = ref<any[]>([])
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
// 审批弹出框相关
const approvalDialogVisible = ref(false)
const stockingInfo = ref<any>({})
// 回显视图模型,字段与模板一致
const header = reactive({ order_no: '', salesman: '' })
const base = reactive({
pn_no: '',
date: '',
close_date: '',
channel: '',
customer_name: '',
client_country: '',
cif_total: '',
cif_total_currency: '',
contract_rate: ''
})
const items = ref<any[]>([])
const approvalNotes = ref('')
const approvalAction = ref('')
onMounted(() => {
getList()
})
// 根据 flow_type 获取审批单名称的字体颜色
const getFlowTypeColor = (flowType: string): string => {
switch (flowType) {
case 'stock_orders':
return '#409EFF' // 蓝色 - 备货单
case 'stock_orders_nullify':
return '#F56C6C' // 红色 - 备货单作废
case 'purchase_orders':
return '#67C23A' // 绿色 - 采购单
case 'purchase_orders_nullify':
return '#E6A23C' // 橙色 - 采购单作废
case 'return_orders':
return '#909399' // 灰色 - 退货单
case 'refund_orders':
return '#9C27B0' // 紫色 - 退款单
default:
return '#303133' // 默认黑色
}
}
watch([keyword], () => {
pagination.currentPage = 1
// 不自动请求,保持与截图行为一致,点击搜索或回车触发
})
const getList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params = {
current: currentPage,
size: pageSize,
scene: 'history',
keyword: keyword.value
}
const { records, current, size, total } = await manageService.stockapprovalrecord(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
getList()
}
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
getList()
}
const handleView = (row: any) => {
router.push({
path: '/admin/approval/stocking_auth_detil',
query: {
id: row.id,
biz_id: row.biz_id,
instance_id: row.instance_id,
status: row.status,
flow_type: row.flow_type,
step_index: row.step_index
} // 可带客户名等参数
})
}
const instance_id = ref('')
const handleCreateRepair = async (row: any) => {
instance_id.value = row.instance_id
const res = await manageService.checkStock({ id: row.biz_id })
const orderData: OrderData = res
orderDialog.orderData = orderData
approvalDialogVisible.value = true
}
// 审批操作
const handleApprove = async (approvalAction: string) => {
try {
// 这里调用审批API
const { message } = await manageService.stockapprovalpass({
instance_id: instance_id.value,
action: approvalAction,
comment: approvalNotes.value
})
ElMessage.success(message)
approvalDialogVisible.value = false
approvalNotes.value = ''
instance_id.value = ''
// 刷新列表
getList()
} catch (error) {
console.error('审批失败:', error)
ElMessage.error('审批失败')
}
}
</script>
<style scoped lang="scss">
.avatar {
width: 40px;
height: 40px;
border-radius: 6px;
margin-right: 10px;
}
.stocking-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.stocking-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--art-main-bg-color);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-right {
display: flex;
align-items: center;
}
.clickable {
cursor: pointer;
}
// 审批弹出框样式
.approval-dialog {
.approval-content {
max-height: 70vh;
overflow-y: auto;
}
.header {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 12px;
color: #333333;
.mr40 { margin-right: 40px; }
}
.info-section {
margin-bottom: 24px;
.section-title {
display: flex;
align-items: center;
margin-bottom: 16px;
.title-bar {
width: 4px;
height: 20px;
background: #409eff;
margin-right: 8px;
border-radius: 2px;
}
span {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
}
.order-items {
.order-item {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: #fafafa;
.item-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e4e7ed;
.item-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.item-details {
.detail-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 12px;
.detail-item {
display: flex;
flex-direction: column;
label {
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: #606266;
}
}
}
}
}
}
.summary-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f0f9ff;
border-radius: 8px;
margin-bottom: 24px;
.summary-item {
.summary-label {
font-weight: 600;
color: #606266;
margin-right: 8px;
}
.summary-value {
font-size: 18px;
font-weight: 700;
color: #409eff;
}
}
}
.approval-notes {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: space-around;
width: 500px;
margin: auto;
gap: 12px;
.reject-btn {
border-color: #409eff;
color: #409eff;
background: white;
&:hover {
background: #ecf5ff;
}
}
.approve-btn {
background: #409eff;
border-color: #409eff;
}
}
}
</style>
+12 -1
View File
@@ -85,7 +85,8 @@
</div>
</div>
<el-dialog v-model="detailDialogVisible" title="详情">
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content" @click="onDescImgClick"></div>
<el-image-viewer v-if="descViewerUrl" :url-list="[descViewerUrl]" hide-on-click-modal @close="descViewerUrl = ''" />
</el-dialog>
<el-dialog v-model="audetDialogVisible" title="审核" width="700px">
<!-- 客户标记无效 原因与附件回显 -->
@@ -167,6 +168,7 @@
import { ref, reactive, onMounted, watch } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { manageService } from '@/api/adminApi'
defineOptions({ name: 'AdminCustomerAudit' })
const { hasAuth } = useAuth()
const keyword = ref<string>('')
const loading = ref(false)
@@ -326,9 +328,18 @@
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.style.cursor = 'zoom-in'
})
return doc.body.innerHTML
}
// 点击 desc_collect 中的图片 -> el-image-viewer 放大查看
const descViewerUrl = ref('')
function onDescImgClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.tagName === 'IMG') {
descViewerUrl.value = (target as HTMLImageElement).src
}
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
+12 -1
View File
@@ -145,7 +145,8 @@
</div>
</div>
<div class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content" @click="onDescImgClick"></div>
<el-image-viewer v-if="descViewerUrl" :url-list="[descViewerUrl]" hide-on-click-modal @close="descViewerUrl = ''" />
</el-dialog>
<!-- 分配跟单对话框 -->
<el-dialog v-model="zyDialogVisible" title="分配" width="360px">
@@ -257,6 +258,7 @@
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import emptyimg from '@/assets/img/crm/empty.png'
defineOptions({ name: 'AdminHighseas' })
const TIMELINE_PLACEMENT = 'top'
const activecolor = '#3471ff'
const { hasAuth } = useAuth()
@@ -347,9 +349,18 @@
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.style.cursor = 'zoom-in'
})
return doc.body.innerHTML
}
// 点击 desc_collect 中的图片 -> el-image-viewer 放大查看
const descViewerUrl = ref('')
function onDescImgClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.tagName === 'IMG') {
descViewerUrl.value = (target as HTMLImageElement).src
}
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
+39 -4
View File
@@ -19,6 +19,17 @@
</el-option>
</el-select>
</el-form-item>
<el-form-item label="客户渠道" prop="channel_id">
<el-select v-model="selectchannel_id" filterable placeholder="请选择客户渠道">
<el-option label="全部" value="" />
<el-option
v-for="item in channelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<el-button type="primary" @click="exportfn" v-if="hasAuth('customer@export')">
<i class="iconfont-sys">&#xe89f;</i> 导出
@@ -227,7 +238,8 @@
</div>
</div>
<div v-if="detailRow.desc_collect != ''" class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content" @click="onDescImgClick"></div>
<el-image-viewer v-if="descViewerUrl" :url-list="[descViewerUrl]" hide-on-click-modal @close="descViewerUrl = ''" />
</el-dialog>
<!-- 转移跟单对话框 -->
<el-dialog v-model="zyDialogVisible" title="转移" width="360px">
@@ -341,10 +353,12 @@
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import emptyimg from '@/assets/img/crm/empty.png'
defineOptions({ name: 'AdminCustomer' })
const TIMELINE_PLACEMENT = 'top'
const activecolor = '#3471ff'
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const selectchannel_id = ref<string>('')
const showMore = ref(false)
const loading = ref(false)
const detailDialogVisible = ref(false)
@@ -372,6 +386,12 @@
customer_id: '',
user_id: ''
})
const channelOptions = ref<any>()
//渠道
const getgetChannel = async () => {
const res = await CrmService.getChannel()
channelOptions.value = res || []
}
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
@@ -401,12 +421,13 @@
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getgetChannel()
getuserList()
gettableList()
tagGrouplistfn()
})
// tabs 切换联动
watch([selectValue, keyword], () => {
watch([selectValue, keyword, selectchannel_id], () => {
pagination.currentPage = 1
gettableList()
})
@@ -424,6 +445,9 @@
if (keyword.value) {
params.keyword = keyword.value
}
if (selectchannel_id.value) {
params.channel_id = selectchannel_id.value
}
// 标签参数,所有选中的标签id组成数组
const selectedTagIds = Object.values(tagActiveMap).filter(Boolean)
if (selectedTagIds.length) {
@@ -463,7 +487,9 @@
current: currentPage,
size: pageSize,
user_id: selectValue.value.join(','),
tag_id: Object.values(tagActiveMap).filter(Boolean)
tag_id: Object.values(tagActiveMap).filter(Boolean),
keyword: keyword.value,
channel_id: selectchannel_id.value
}
// manageService.crmCustomerExport(params)
try {
@@ -471,7 +497,7 @@
// const res = await manageService.crmCustomerExport(params)
const urls =
import.meta.env.VITE_API_URL +
`/admin/crm/customer/export?current=${params.currentPage}&size=${params.pageSize}&user_id=${params.user_id}&tag_id=${params.tag_id}`
`/admin/crm/customer/export?current=${params.current}&size=${params.size}&user_id=${params.user_id}&tag_id=${params.tag_id}&channel_id=${params.channel_id}&keyword=${params.keyword}`
// 2. 创建blob对象
// const blob = new Blob([res], { type: 'application/vnd.ms-excel' }) // 或根据后端实际类型调整
// 3. 创建下载链接
@@ -503,9 +529,18 @@
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.style.cursor = 'zoom-in'
})
return doc.body.innerHTML
}
// 点击 desc_collect 中的图片 -> el-image-viewer 放大查看
const descViewerUrl = ref('')
function onDescImgClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.tagName === 'IMG') {
descViewerUrl.value = (target as HTMLImageElement).src
}
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
+44 -14
View File
@@ -57,8 +57,13 @@
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<el-table-column prop="user_name" label="跟单人"></el-table-column>
<el-table-column prop="del_source" label="来源"></el-table-column>
<el-table-column prop="follow_last" label="上次跟进"></el-table-column>
<el-table-column prop="follow_next" label="下次跟进"></el-table-column>
<el-table-column
prop="status_desc"
label="删除原因"
show-overflow-tooltip
></el-table-column>
<!-- <el-table-column prop="follow_last" label="上次跟进"></el-table-column> -->
<!-- <el-table-column prop="follow_next" label="下次跟进"></el-table-column> -->
<el-table-column
prop="code"
label="编号"
@@ -139,7 +144,8 @@
</div>
</div>
<div v-if="detailRow.desc_collect != ''" class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content" @click="onDescImgClick"></div>
<el-image-viewer v-if="descViewerUrl" :url-list="[descViewerUrl]" hide-on-click-modal @close="descViewerUrl = ''" />
</el-dialog>
<!-- 图片预览对话框 -->
<el-image-viewer
@@ -154,6 +160,7 @@
import { ref, reactive, onMounted, watch } from 'vue'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
defineOptions({ name: 'AdminRecycle' })
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const loading = ref(false)
@@ -231,9 +238,18 @@
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.style.cursor = 'zoom-in'
})
return doc.body.innerHTML
}
// 点击 desc_collect 中的图片 -> el-image-viewer 放大查看
const descViewerUrl = ref('')
function onDescImgClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.tagName === 'IMG') {
descViewerUrl.value = (target as HTMLImageElement).src
}
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
@@ -256,20 +272,34 @@
})
}
const delectfn = (row: any) => {
ElMessageBox.confirm('确定要删除此客户吗?删除后将无法恢复!', '提示', {
ElMessageBox.prompt('确定要删除此客户吗?请填写入删除原因!', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
inputPattern: /^(?!\s*$).+/,
inputErrorMessage: '请填写入删除原因'
}).then(async ({ value }) => {
const res = await manageService.crmCustomerDelete({
customer_id: row.id,
scene: 'force',
status_desc: value
})
ElMessage.success(res?.message || '提交成功!')
gettableList()
})
.then(async () => {
const res = await manageService.crmCustomerDelete({ customer_id: row.id, scene: 'force' })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
// ElMessageBox.confirm('确定要删除此客户吗?删除后将无法恢复!', '提示', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
// type: 'warning'
// })
// .then(async () => {
// const res = await manageService.crmCustomerDelete({ customer_id: row.id, scene: 'force' })
// ElMessage.success(res?.message || '提交成功!')
// gettableList()
// // 你可以在这里调用接口或做后续处理
// })
// .catch(() => {
// // 用户点击了取消
// })
}
const closeImageViewer = () => {
imageViewerVisible.value = false
+389
View File
@@ -0,0 +1,389 @@
<template>
<div class="account-page">
<div class="account-toolbar boxshadow">
<div class="account-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input
style="min-width: 180px"
v-model="keyword"
placeholder="请输入关键字"
clearable
@keyup.enter="getAccountList"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getAccountList" v-ripple>搜索</el-button>
</el-form-item>
</div>
</div>
<div class="boxshadow">
<ElRow>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('account@add')" type="primary"
>新增账户</ElButton
>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="账户简称" prop="short_desc" min-width="160" />
<ElTableColumn label="账户名称" prop="account_name" min-width="160" />
<ElTableColumn label="账户号码" prop="account_number" min-width="160" />
<ElTableColumn label="银行名称" prop="bank_name" min-width="140" />
<!-- <ElTableColumn label="开户支行" prop="bank_branch" min-width="160" /> -->
<ElTableColumn label="公司名称" prop="corp_name" min-width="160" />
<ElTableColumn label="货币" prop="currency_id" min-width="120">
<template #default="scope">
<span>{{ getCurrencyLabel(scope.row.currency_id) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="排序" prop="sort" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status ? 'success' : 'info'">{{
scope.row.status ? '启用' : '停用'
}}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" min-width="160" />
<ElTableColumn fixed="right" label="操作" min-width="160px" align="center">
<template #default="scope">
<ElRow>
<el-button
link
@click="showDialog('edit', scope.row)"
v-if="hasAuth('account@update')"
>编辑</el-button
>
<el-button
type="danger"
link
@click="deleteAccount(scope.row)"
v-if="hasAuth('account@destroy')"
>删除</el-button
>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增账户' : '编辑账户'"
align-center
width="700px"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="form">
<ElFormItem label="账户简称" prop="short_desc">
<ElInput v-model="form.short_desc" placeholder="请输入账户简称" />
</ElFormItem>
<ElFormItem label="账户名称" prop="account_name">
<ElInput v-model="form.account_name" placeholder="请输入账户名称" />
</ElFormItem>
<ElFormItem label="账户号码" prop="account_number">
<ElInput v-model="form.account_number" placeholder="请输入账户号码" />
</ElFormItem>
<ElFormItem label="银行名称" prop="bank_name">
<ElInput v-model="form.bank_name" placeholder="请输入银行名称" />
</ElFormItem>
<!-- <ElFormItem label="开户支行" prop="bank_branch">
<ElInput v-model="form.bank_branch" placeholder="请输入开户支行" />
</ElFormItem> -->
<ElFormItem label="公司名称" prop="corp_id">
<el-select v-model="form.corp_id" placeholder="请选择公司" style="width: 100%">
<el-option
v-for="account in companyList"
:key="account.id"
:label="account.name"
:value="account.id"
/>
</el-select>
</ElFormItem>
<ElFormItem label="账户货币类型" prop="currency_id">
<el-select v-model="form.currency_id" placeholder="请选择货币" style="width: 100%">
<el-option
v-for="opt in currencyOptions"
:key="opt.id"
:label="opt.currency_name + ' (' + opt.currency_code + ')'"
:value="opt.id"
/>
</el-select>
</ElFormItem>
<ElFormItem label="排序" prop="sort">
<ElInputNumber v-model="form.sort" :min="0" :max="9999" style="width: 100%" />
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
</ElFormItem>
<ElFormItem label="账户描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
maxlength="300"
show-word-limit
placeholder="请输入账户描述"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { financeService } from '@/api/financeApi'
import { commonService } from '@/api/commonApi'
import { useAuth } from '@/composables/useAuth'
import { UserService } from '@/api/usersApi'
const { hasAuth } = useAuth()
defineOptions({ name: 'FinanceAccount' })
const loading = ref(false)
const dialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const keyword = ref<string>('')
const currencyFilter = ref<number | undefined>(undefined)
const statusFilter = ref<number | undefined>(undefined)
// 下拉:货币
const currencyOptions = ref<any[]>([])
const loadCurrencyOptions = async () => {
try {
const res = await commonService.getCurrencySelect()
currencyOptions.value = res || []
} catch (e) {
currencyOptions.value = []
}
}
const companyList = ref<any>()
const getcompanyList = async () => {
const response = await UserService.getCompanyList()
companyList.value = response || []
}
const form = reactive<any>({
id: '',
currency_id: undefined,
short_desc: '',
status: 1,
sort: 0,
bank_name: '',
bank_branch: '',
description: '',
account_number: '',
account_name: '',
corp_id: ''
})
const rules = reactive<FormRules>({
short_desc: [{ required: true, message: '请输入账户简称', trigger: 'blur' }],
account_name: [{ required: true, message: '请输入账户名称', trigger: 'blur' }],
account_number: [{ required: true, message: '请输入账户号码', trigger: 'blur' }]
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const tableData = ref<any[]>([])
const tableRef = ref()
onMounted(() => {
getcompanyList()
loadCurrencyOptions()
getAccountList()
})
watch([keyword, currencyFilter, statusFilter], () => {
pagination.currentPage = 1
getAccountList()
})
const getAccountList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params = {
current: currentPage,
size: pageSize,
keyword: keyword.value
}
const { records, current, size, total } = await financeService.getAccountList(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取账户列表失败:', error)
ElMessage.error('获取账户列表失败')
} finally {
loading.value = false
}
}
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getAccountList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getAccountList()
}
const showDialog = (type: 'add' | 'edit', row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
Object.assign(form, {
id: row.id,
short_desc: row.short_desc,
currency_id: row.currency_id,
status: row.status,
sort: row.sort ?? 0,
bank_name: row.bank_name,
bank_branch: row.bank_branch,
description: row.description,
account_number: row.account_number,
account_name: row.account_name,
corp_id: row.corp_id
})
} else {
Object.assign(form, {
id: '',
short_desc: '',
currency_id: undefined,
status: 1,
sort: 0,
bank_name: '',
bank_branch: '',
description: '',
account_number: '',
account_name: '',
corp_id: ''
})
}
}
const deleteAccount = (row: any) => {
ElMessageBox.confirm('确定删除该账户吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
try {
const { message } = await financeService.accountDelete({ id: row.id })
ElMessage.success(message)
getAccountList()
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (!valid) return
try {
if (dialogType.value === 'add') {
const { message } = await financeService.accountAdd({
currency_id: form.currency_id,
short_desc: form.short_desc,
status: form.status,
sort: form.sort,
bank_name: form.bank_name,
bank_branch: form.bank_branch,
description: form.description,
account_number: form.account_number,
account_name: form.account_name,
corp_id: form.corp_id
})
ElMessage.success(message)
} else {
const { message } = await financeService.accountEdit({
id: form.id,
currency_id: form.currency_id,
short_desc: form.short_desc,
status: form.status,
sort: form.sort,
bank_name: form.bank_name,
bank_branch: form.bank_branch,
description: form.description,
account_number: form.account_number,
account_name: form.account_name,
corp_id: form.corp_id
})
ElMessage.success(message)
}
dialogVisible.value = false
getAccountList()
} catch (error) {
console.error('操作失败:', error)
ElMessage.error('操作失败')
}
})
}
const getCurrencyLabel = (id: number | undefined) => {
if (!id) return '-'
const found = currencyOptions.value.find((c: any) => c.id === id)
return found ? `${found.currency_name} (${found.currency_code})` : id
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.account-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.account-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.account-search-group {
display: flex;
gap: 12px;
align-items: center;
}
.form {
padding: 20px 0;
}
.dialog-footer {
text-align: right;
}
.el-col2 {
margin-bottom: 16px;
}
</style>
+301
View File
@@ -0,0 +1,301 @@
<template>
<div class="stocking-page">
<div class="stocking-toolbar boxshadow">
<div class="toolbar-left"></div>
<div class="toolbar-right">
<el-input
v-model="keyword"
placeholder="请输入客户名称"
clearable
style="width: 260px"
@keyup.enter="getList"
>
<template #suffix>
<el-icon class="clickable" @click="getList"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="boxshadow">
<el-tabs v-model="activeTab">
<el-tab-pane label="未认领" name="not"></el-tab-pane>
<el-tab-pane label="已认领" name="already"></el-tab-pane>
<el-tab-pane label="超时未认领" name="timeout"></el-tab-pane>
</el-tabs>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:row-class-name="tableRowClassName"
>
<template #default>
<ElTableColumn label="创建日期" prop="created_at" min-width="160" />
<!-- <ElTableColumn label="付款来源" prop="country_name" min-width="120" /> -->
<ElTableColumn label="汇款人" prop="remitter" min-width="120" />
<ElTableColumn label="账户" prop="account_name" min-width="120" />
<ElTableColumn label="账币" prop="currency_name" min-width="120" />
<ElTableColumn label="金额" prop="amount" min-width="120" />
<ElTableColumn label="计时" prop="amount" min-width="120">
<template #default="scope">
<span
v-if="!scope.row.payment_apply_id"
:class="getCountdownClass(scope.row.claim_timeout_time)"
>
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px">
<i class="iconfont-sys">&#xe7b1;</i>
</span>
{{ getCountdown(scope.row.claim_timeout_time) }}
</span>
<span v-else class="countdown-success">已认领</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" fixed="right" min-width="140" align="center">
<template #default="scope">
<ElRow :gutter="10" justify="center">
<el-link
type="primary"
underline="never"
@click="handleView(scope.row)"
style="margin-right: 8px"
v-if="hasAuth('claim@show')"
>
<i class="iconfont-sys">&#xe76c;</i> 查看
</el-link>
<el-link
v-if="!scope.row.payment_apply_id && hasAuth('claim@claim')"
type="success"
underline="never"
@click="handleClaim(scope.row)"
>
<i class="iconfont-sys">&#xe6bf;</i> 认领
</el-link>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 付款记录选择对话框 -->
<PaymentRecordDialog
v-model="paymentDialogVisible"
:receipt-id="currentReceiptId"
@confirm="handlePaymentRecordConfirm"
/>
<!-- 付款记录详情查看对话框 -->
<ClaimRecordDetailDialog v-model="detailDialogVisible" :payment-id="currentPaymentId" />
</div>
</template>
<script setup lang="ts">
import { manageService } from '@/api/adminApi'
import { financeService } from '@/api/admin/finance'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuth } from '@/composables/useAuth'
import PaymentRecordDialog from '@/components/custom/PaymentRecordDialog.vue'
import ClaimRecordDetailDialog from '@/components/custom/ClaimRecordDetailDialog.vue'
import dayjs from 'dayjs'
const { hasAuth } = useAuth()
defineOptions({ name: 'Adminclaim' })
const loading = ref(false)
const keyword = ref('')
const tableRef = ref()
const tableData = ref<any[]>([])
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
const activeTab = ref('not')
const detailDialogVisible = ref(false)
// 付款记录对话框相关
const paymentDialogVisible = ref(false)
const currentReceiptId = ref<string | number>('')
watch([keyword], () => {
pagination.currentPage = 1
getList()
// 不自动请求,保持与截图行为一致,点击搜索或回车触发
})
watch([activeTab], () => {
pagination.currentPage = 1
getList()
})
const getList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params = {
current: currentPage,
size: pageSize,
payment_apply: activeTab.value,
keyword: keyword.value
}
const { records, current, size, total } = await financeService.getReceiptList(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
getList()
}
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
getList()
}
const currentPaymentId = ref<string | number>('')
const handleView = async (params: any) => {
detailDialogVisible.value = true
currentPaymentId.value = params.id
await financeService.getReceiptDetail(params)
}
// 处理认领操作
const handleClaim = (row: any) => {
currentReceiptId.value = row.id
paymentDialogVisible.value = true
}
// 处理付款记录确认绑定
const handlePaymentRecordConfirm = async (paymentRecord: any) => {
try {
const params = {
id: currentReceiptId.value,
payment_apply_id: paymentRecord.id
}
await financeService.bindPaymentRecord(params)
ElMessage.success('认领成功')
getList() // 刷新列表
} catch (error) {
console.error('认领失败:', error)
ElMessage.error('认领失败,请稍后重试')
}
}
const handleDelete = () => {}
const timer = ref(0)
const refreshKey = ref(0)
onMounted(() => {
getList()
timer.value = window.setInterval(() => {
refreshKey.value++
}, 1000)
})
function getCountdown(targetDate: string) {
// 让 refreshKey 成为依赖,确保每秒刷新
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
refreshKey.value
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid()) return '时间格式错误'
const diff = target.diff(now)
if (diff <= 0) return '已超时'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24)
const minutes = Math.floor((diff / (1000 * 60)) % 60)
const seconds = Math.floor((diff / 1000) % 60)
return `${days > 0 ? days + '天' : ''}${hours}小时${minutes}分${seconds}秒`
}
function getCountdownClass(targetDate: string) {
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid() || target.diff(now) <= 0) {
return 'countdown-danger'
}
return 'countdown-success'
}
const tableRowClassName = ({ row }: { row: any }) => {
const flag = Number(row?.claim_timeout)
return flag === 1 ? 'row-claim-timeout' : ''
}
</script>
<style scoped lang="scss">
:deep(.row-claim-timeout) {
background-color: #ffc9c9 !important;
}
:deep(.el-tabs__nav-wrap:after){
background: #fff;
}
.countdown-danger {
color: #ff0000 !important;
}
.countdown-success {
color: #00a43f !important;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 6px;
margin-right: 10px;
}
.stocking-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.stocking-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--art-main-bg-color);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-right {
display: flex;
align-items: center;
}
.clickable {
cursor: pointer;
}
// 创建收款记录 - 弹窗样式对齐设计图
.receipt-dialog {
.receipt-form {
.el-form-item {
margin-bottom: 20px;
}
}
.upload-demo {
width: 100%;
display: flex;
align-items: center;
// justify-content: flex-end;
// border: 1px dashed #dcdfe6;
border-radius: 6px;
cursor: pointer;
}
.avatarimg {
width: 60px;
cursor: pointer;
}
.el-button {
width: 200px;
height: 40px;
font-size: 16px;
}
}
:deep(.receipt-dialog .el-dialog__footer) {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
</style>
+389
View File
@@ -0,0 +1,389 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
</div>
<div class="boxshadow">
<ElRow>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('currency@add')"
>新增</ElButton
>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="货币编码" prop="currency_code" min-width="140" />
<ElTableColumn label="货币名称" prop="currency_name" min-width="140" />
<ElTableColumn label="符号" prop="currency_symbol" width="100" />
<ElTableColumn label="当前汇率" prop="exchange_rate" min-width="120" />
<ElTableColumn label="允许上浮动%" prop="float_percent_up" min-width="120" />
<ElTableColumn label="允许下浮动%" prop="float_percent_down" min-width="120" />
<ElTableColumn label="状态" prop="status" width="120">
<template #default="scope">
<ElTag :type="scope.row.status ? 'primary' : 'info'">
{{ scope.row.status ? '启用' : '停用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" min-width="160" />
<ElTableColumn fixed="right" label="操作" width="240px">
<template #default="scope">
<ElRow>
<el-button link @click="showDetail(scope.row)" v-if="hasAuth('currency@show')">
详情
</el-button>
<el-button
link
@click="showDialog('edit', scope.row)"
v-if="hasAuth('currency@update')"
>
编辑
</el-button>
<el-button
type="danger"
link
@click="handleDelete(scope.row)"
v-if="hasAuth('currency@destroy')"
>
删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增货币' : '编辑货币'"
align-center
width="640px"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
<ElFormItem label="货币编码" prop="currency_code">
<ElInput v-model="form.currency_code" />
</ElFormItem>
<ElFormItem label="货币名称" prop="currency_name">
<ElInput v-model="form.currency_name" />
</ElFormItem>
<ElFormItem label="货币符号" prop="currency_symbol">
<ElInput v-model="form.currency_symbol" />
</ElFormItem>
<ElFormItem label="当前汇率" prop="exchange_rate">
<ElInputNumber
v-model="form.exchange_rate"
:min="0"
:step="0.0001"
:precision="6"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="允许上浮动%" prop="float_percent_up">
<ElInputNumber
v-model="form.float_percent_up"
:min="0"
:max="100"
:step="0.01"
:precision="2"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="允许下浮动%" prop="float_percent_down">
<ElInputNumber
v-model="form.float_percent_down"
:min="0"
:max="100"
:step="0.01"
:precision="2"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<ElDialog v-model="detailVisible" title="货币详情" align-center width="640px">
<div class="detail-dialog">
<div class="section">
<div class="info-grid">
<div class="label">货币编码</div>
<div class="value">{{ detailData.currency_code || '-' }}</div>
</div>
<div class="info-grid">
<div class="label">货币名称</div>
<div class="value">{{ detailData.currency_name || '-' }}</div>
</div>
<div class="info-grid">
<div class="label">货币符号</div>
<div class="value">{{ detailData.currency_symbol || '-' }}</div>
</div>
<div class="info-grid">
<div class="label">当前汇率</div>
<div class="value">{{ detailData.exchange_rate ?? '-' }}</div>
</div>
<div class="info-grid">
<div class="label">允许上浮动%</div>
<div class="value">{{ detailData.float_percent_up ?? '-' }}</div>
</div>
<div class="info-grid">
<div class="label">允许下浮动%</div>
<div class="value">{{ detailData.float_percent_down ?? '-' }}</div>
</div>
<div class="info-grid">
<div class="label">状态</div>
<div class="value">{{ detailData.status ? '启用' : '停用' }}</div>
</div>
<div class="info-grid">
<div class="label">创建时间</div>
<div class="value">{{ detailData.created_at || '-' }}</div>
</div>
<div class="info-grid">
<div class="label">更新时间</div>
<div class="value">{{ detailData.updated_at || '-' }}</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="detailVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { financeService } from '@/api/financeApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'TypeCurrency' })
const loading = ref(false)
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const keyword = ref<string>('')
const rules = reactive<FormRules>({
currency_code: [{ required: true, message: '请输入货币编码', trigger: 'blur' }],
currency_name: [{ required: true, message: '请输入货币名称', trigger: 'blur' }],
exchange_rate: [{ required: true, message: '请输入当前汇率', trigger: 'change' }]
})
const form = reactive<any>({
id: '',
currency_code: '',
currency_name: '',
currency_symbol: '',
exchange_rate: 0,
float_percent_up: 0,
float_percent_down: 0,
status: 1
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getCurrencyList()
})
watch([keyword], () => {
pagination.currentPage = 1
getCurrencyList()
})
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getCurrencyList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getCurrencyList()
}
// 获取货币列表
const getCurrencyList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await financeService.getCurrencyList({
current: currentPage,
size: pageSize,
keyword: keyword.value
})
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const dialogType = ref('add')
const detailVisible = ref(false)
const detailData = ref<any>({})
const showDialog = (type: 'add' | 'edit', row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
Object.assign(form, {
id: row.id,
currency_code: row.currency_code,
currency_name: row.currency_name,
currency_symbol: row.currency_symbol,
exchange_rate: row.exchange_rate ?? 0,
float_percent_up: row.float_percent_up ?? 0,
float_percent_down: row.float_percent_down ?? 0,
status: row.status ?? 1
})
} else {
Object.assign(form, {
id: '',
currency_code: '',
currency_name: '',
currency_symbol: '',
exchange_rate: 0,
float_percent_up: 0,
float_percent_down: 0,
status: 1
})
}
}
const handleDelete = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await financeService.currencyDelete({ id: row.id })
ElMessage.success(message)
getCurrencyList()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (!valid) return
if (dialogType.value === 'add') {
const { id, ...payload } = form
const { message } = await financeService.currencyAdd(payload)
ElMessage.success(message)
} else {
const { message } = await financeService.currencyEdit(form)
ElMessage.success(message)
}
dialogVisible.value = false
getCurrencyList()
})
}
const showDetail = async (row: any) => {
detailVisible.value = true
const res = await financeService.currencyDetail({ id: row.id })
detailData.value = res || {}
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.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>
File diff suppressed because it is too large Load Diff
+838
View File
@@ -0,0 +1,838 @@
<template>
<div class="stocking-page">
<div class="stocking-toolbar boxshadow">
<div class="toolbar-left">
<ElButton
type="primary"
v-ripple
@click="handleCreate"
v-if="hasAuth('receipt_records@add')"
>
创建收款记录
</ElButton>
</div>
<div class="toolbar-right">
<el-input
v-model="keyword"
placeholder="请输入客户名称"
clearable
style="width: 260px"
@keyup.enter="getList"
>
<template #suffix>
<el-icon class="clickable" @click="getList"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="boxshadow">
<ArtTable
ref="tableRef"
row-key="id"
:key="tableData.length"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:row-class-name="tableRowClassName"
>
<template #default>
<ElTableColumn label="创建日期" prop="created_at" min-width="140" />
<!-- <ElTableColumn label="付款来源" prop="country_name" min-width="160" /> -->
<ElTableColumn label="汇款人" prop="remitter" min-width="120" />
<ElTableColumn label="账户" prop="account_short_desc" min-width="120" />
<ElTableColumn label="币种" prop="currency_name" min-width="120" />
<ElTableColumn label="金额" prop="amount" min-width="120" />
<ElTableColumn label="折让金额(含手续费)" prop="discount_amount" min-width="120" />
<ElTableColumn label="认领状态" prop="payment_apply_id" min-width="120">
<template #default="scope">
<span
v-if="scope.row.payment_apply_id == 0 || scope.row.payment_apply_id == null"
class="text-danger"
>
未认领
</span>
<span v-else class="text-success">已认领</span>
</template>
</ElTableColumn>
<ElTableColumn label="入账" prop="id" min-width="120">
<template #default="scope">
<span v-if="scope.row.received_at">{{ scope.row.received_at }}</span>
<el-button
v-else-if="hasAuth('receipt_records@confirmentry')"
type="primary"
size="small"
@click="handlebtn(scope.row)"
>
确认入账
</el-button>
<span v-else></span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" fixed="right" min-width="180" align="center">
<template #default="scope">
<ElRow :gutter="10" justify="center">
<el-link
v-if="hasAuth('receipt_records@show')"
type="primary"
underline="never"
@click="handleView(scope.row)"
style="margin-right: 8px"
>
<i class="iconfont-sys">&#xe76c;</i> 查看
</el-link>
<el-link
v-if="hasAuth('receipt_records@update')"
type="success"
underline="never"
@click="handleEdit(scope.row)"
style="margin-right: 8px"
>
<i class="iconfont-sys">&#xe6bf;</i> 修改
</el-link>
<!-- <el-link
v-if="hasAuth('receipt_records@destroy')"
type="danger"
underline="never"
@click="handleDelete()"
style="margin-right: 8px"
>
<i class="iconfont-sys">&#xe6d4;</i> 作废
</el-link> -->
<el-link
v-if="hasAuth('receipt_records@history')"
type="warning"
underline="never"
@click="handleHistory(scope.row)"
>
<i class="iconfont-sys">&#xe64e;</i> 历史记录
</el-link>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 新建/编辑收款记录 - 通用弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑收款记录' : '创建收款记录'"
width="900px"
class="receipt-dialog"
>
<el-form
ref="createFormRef"
:model="form"
:rules="createRules"
label-width="180px"
class="receipt-form"
>
<ElRow :gutter="12" class="item-row-wrap">
<!-- <ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="付款来源">
<el-select v-model="form.country_id" placeholder="请选择付款国家">
<el-option
v-for="item in countryOptions"
:key="item.id"
:label="item.cn_addrs"
:value="item.id"
></el-option>
</el-select>
</el-form-item>
</ElCol> -->
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="汇款人">
<el-input v-model="form.remitter" placeholder="请填写汇款人" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="收款账户">
<el-select v-model="form.account_id" placeholder="请选择收款账户">
<el-option
v-for="item in accountOptions"
:key="item.id"
:label="item.short_desc"
:value="item.id"
></el-option>
</el-select>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="币种">
<el-select v-model="form.currency_id" placeholder="请选择币种">
<el-option
v-for="item in currencyOptions"
:key="item.id"
:label="item.currency_name"
:value="item.id"
></el-option>
</el-select>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="金额">
<el-input v-model="form.amount" placeholder="请填写金额" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="到账时间">
<el-date-picker
v-model="form.arrived_at"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
type="date"
placeholder="请选择到账时间"
style="width: 100%"
/>
</el-form-item>
</ElCol>
<!-- <ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="折让金额">
<el-input v-model="form.discount_amount" placeholder="请填写折让金额" />
</el-form-item>
</ElCol> -->
<ElCol :sm="24" :md="12" :lg="12" v-if="isEdit">
<el-form-item label="收汇凭证号">
<el-input v-model="form.voucher_no" placeholder="请填写收汇凭证号" />
</el-form-item>
</ElCol>
<!-- <ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="实际汇率(出纳)">
<el-input v-model="form.exchange_rate" placeholder="请填写实际汇率" />
</el-form-item>
</ElCol> -->
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="总金额(人民币)实际汇率">
<el-input
v-model="form.total_amount_cny"
placeholder="请填写总金额(人民币)实际汇率"
/>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="是否转回内地">
<el-radio-group v-model="form.is_domestic_return">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="24" :lg="24">
<el-form-item label="外汇到账回单(出纳)">
<el-upload
class="upload-demo"
:action="actionImageUplode"
:headers="uploadHeaders"
:on-success="handleUploadSuccess1"
:show-file-list="false"
:multiple="true"
>
<img :src="imageUrl1" alt="" class="avatarimg" />
<input type="hidden" v-model="form.fx_receipt_file" />
</el-upload>
<div
v-if="Array.isArray(form.fx_receipt_file) && form.fx_receipt_file.length"
style="margin-top: 8px"
>
<div
v-for="(u, i) in form.fx_receipt_file"
:key="i"
style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px"
>
<img
v-if="
/\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(
(u.file_full || '').split('?')[0]
)
"
:src="u.file_full"
style="
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--el-border-color);
"
/>
<a
v-else
:href="u.file_full"
target="_blank"
rel="noopener"
style="
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 200px;
"
>{{ u.file_name }}</a
>
<el-button link type="danger" @click="removeVoucher(i)" style="width: 80px"
>移除</el-button
>
</div>
</div>
</el-form-item>
</ElCol>
</ElRow>
</el-form>
<template #footer>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
<el-dialog
v-model="dialogVisibleshow"
title="查看收款记录"
width="900px"
class="receipt-dialog"
>
<div class="info-grid">
<div class="info-item">
<label>创建日期:</label>
<span>{{ formshow.created_at }}</span>
</div>
<!-- <div class="info-item">
<label>付款来源:</label>
<span>{{ formshow.country_name || '—' }}</span>
</div> -->
<div class="info-item">
<label>汇款人:</label>
<span>{{ formshow.remitter || '—' }}</span>
</div>
<div class="info-item">
<label>账户:</label>
<span>{{ formshow.account_name || '—' }}</span>
</div>
<div class="info-item">
<label>币种:</label>
<span>{{ formshow.currency_name || '—' }}</span>
</div>
<div class="info-item">
<label>金额:</label>
<span class="amount">{{ formshow.amount }}</span>
</div>
<div class="info-item">
<label>到账时间:</label>
<span>{{ formshow.arrived_at || '—' }}</span>
</div>
<!-- <div class="info-item">
<label>折让金额:</label>
<span>{{ formshow.discount_amount || '—' }}</span>
</div> -->
<div class="info-item">
<label>收汇凭证号:</label>
<span>{{ formshow.voucher_no || '—' }}</span>
</div>
<!-- <div class="info-item">
<label>实际汇率(出纳):</label>
<span>{{ formshow.exchange_rate || '—' }}</span>
</div> -->
<div class="info-item">
<label>总金额(人民币)实际汇率:</label>
<span>{{ formshow.total_amount_cny || '—' }}</span>
</div>
<div class="info-item">
<label>是否转回内地:</label>
<span>{{ formshow.is_domestic_return == 1 ? '是' : '否' }}</span>
</div>
<div class="info-item">
<label>外汇到账回单:</label>
<div>
<p v-for="(item, index) in formshow.fx_receipt_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>
</el-dialog>
<!-- 历史记录 -->
<el-dialog
v-model="timelineDialogVisible"
title="历史记录"
width="600px"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="approval-dialog"
>
<div class="approval-content">
<!-- 头部信息 -->
<Timeline :records="approvalRecords" />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { commonService } from '@/api/commonApi'
import { financeService } from '@/api/admin/finance'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuth } from '@/composables/useAuth'
import imageUrl1 from '@/assets/img/crm/upimg.png'
import { useUploadHeaders } from '@/composables/useUploadHeaders'
import { defineAsyncComponent } from 'vue'
const Timeline = defineAsyncComponent(() => import('@/components/custom/Timeline.vue'))
const { uploadHeaders } = useUploadHeaders()
let actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=receipt_records`
defineOptions({ name: 'AdminReceiptRecords' })
const approvalRecords = ref<any[]>([])
const { hasAuth } = useAuth()
const loading = ref(false)
const dialogVisibleshow = ref(false)
const timelineDialogVisible = ref(false)
const keyword = ref('')
const tableRef = ref()
const tableData = ref<any[]>([])
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
//国家
const countryOptions = ref<any[]>([])
const fetchCountryOptions = async () => {
try {
const res = await commonService.getCountrySelect()
res.forEach((item: any) => {
item.cn_addrs = item.cn_addr + '(' + item.en_addr + ')'
})
countryOptions.value = res || []
} catch {
countryOptions.value = []
}
}
//账户
const accountOptions = ref<any[]>([])
const fetchAccountOptions = async () => {
try {
const res = await commonService.getAccountSelect()
accountOptions.value = res || []
} catch {
accountOptions.value = []
}
}
//币种
const currencyOptions = ref<any[]>([])
const fetchCurrencyOptions = async () => {
try {
const res = await commonService.getCurrencySelect()
currencyOptions.value = res || []
} catch {
currencyOptions.value = []
}
}
// 审批弹出框相关
onMounted(() => {
getList()
})
watch([keyword], () => {
pagination.currentPage = 1
getList()
// 不自动请求,保持与截图行为一致,点击搜索或回车触发
})
const getList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params = {
current: currentPage,
size: pageSize,
keyword: keyword.value
}
const { records, current, size, total } = await financeService.getReceiptList(params)
tableData.value = records || []
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const tableRowClassName = ({ row }: { row: any }) => {
const flag = Number(row?.claim_timeout)
return flag === 1 ? 'row-claim-timeout' : ''
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
getList()
}
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
getList()
}
// 新建/编辑弹窗通用状态
const dialogVisible = ref(false)
const createLoading = ref(false)
const isEdit = ref(false)
const currentId = ref<string | number | null>(null)
const createFormRef = ref<FormInstance>()
interface FileInfo {
file_name?: string
file_full?: string
file_src?: string
}
const form = ref({
remitter: '',
account_id: '',
currency_id: '',
amount: '',
arrived_at: new Date().toISOString().split('T')[0],
voucher_no: '',
total_amount_cny: '',
is_domestic_return: 0,
fx_receipt_file: [] as FileInfo[],
fx_receipt_file_list: [] as object[]
})
const createRules: FormRules = {
remitter: [{ required: true, message: '请输入汇款人', trigger: 'blur' }],
account_id: [{ required: true, message: '请选择账户', trigger: 'change' }],
currency_id: [{ required: true, message: '请选择币种', trigger: 'change' }]
}
const formshow = ref({
created_at: '',
country_name: '',
remitter: '',
account_name: '',
currency_name: '',
amount: '',
arrived_at: '',
voucher_no: '',
total_amount_cny: '',
is_domestic_return: 0,
fx_receipt_file: [] as FileInfo[],
fx_receipt_file_full: '',
fx_receipt_file_list: [] as object[]
})
const resetCreateForm = () => {
Object.assign(form.value, {
remitter: '',
account_id: '',
currency_id: '',
amount: '',
arrived_at: new Date().toISOString().split('T')[0],
voucher_no: '',
total_amount_cny: '',
is_domestic_return: 0,
fx_receipt_file: [] as FileInfo[],
fx_receipt_file_list: []
})
currentId.value = null
createFormRef.value?.clearValidate?.()
}
const handleCreate = async () => {
resetCreateForm()
isEdit.value = false
fetchAccountOptions()
fetchCurrencyOptions()
fetchCountryOptions()
dialogVisible.value = true
}
const handleSubmit = () => {
createFormRef.value?.validate?.(async (valid: boolean) => {
if (!valid) return
try {
createLoading.value = true
// TODO: 根据新增/编辑调用对应接口
if (isEdit.value) {
const formData = { ...form.value }
// if ('fx_receipt_file_list' in formData) {
// delete formData.fx_receipt_file_list
// }
const forms = { ...formData, id: currentId.value }
const { message } = await financeService.updateReceipt({
remitter: forms.remitter,
account_id: forms.account_id,
currency_id: forms.currency_id,
amount: forms.amount,
arrived_at: forms.arrived_at,
voucher_no: forms.voucher_no,
total_amount_cny: forms.total_amount_cny,
is_domestic_return: forms.is_domestic_return,
fx_receipt_file: forms.fx_receipt_file,
id: forms.id
})
ElMessage.success(message)
} else {
const formData = { ...form.value }
const { message } = await financeService.addReceipt({
remitter: formData.remitter,
account_id: formData.account_id,
currency_id: formData.currency_id,
amount: formData.amount,
arrived_at: formData.arrived_at,
voucher_no: formData.voucher_no,
total_amount_cny: formData.total_amount_cny,
is_domestic_return: formData.is_domestic_return,
fx_receipt_file: formData.fx_receipt_file
})
ElMessage.success(message)
}
dialogVisible.value = false
getList()
} finally {
createLoading.value = false
}
})
}
const handleView = (row?: any) => {
// const fx_receipt_file_full = []
// if (row.fx_receipt_file_full) {
// fx_receipt_file_full.push(row.fx_receipt_file_full)
// }
formshow.value = row
// formshow.value.fx_receipt_file_list = fx_receipt_file_full
dialogVisibleshow.value = true
}
const handleEdit = (row?: any) => {
resetCreateForm()
isEdit.value = true
currentId.value = row?.id ?? null
if (row && typeof row === 'object') {
// form.value.country_id = row.country_id ?? form.value.country_id
form.value.remitter = row.remitter ?? form.value.remitter
form.value.account_id = row.account_id ?? form.value.account_id
form.value.currency_id = row.currency_id ?? form.value.currency_id
form.value.amount = row.amount ?? form.value.amount
form.value.arrived_at = row.arrived_at ?? form.value.arrived_at
// form.value.discount_amount = row.discount_amount ?? form.value.discount_amount
form.value.voucher_no = row.voucher_no ?? form.value.voucher_no
form.value.total_amount_cny = row.total_amount_cny ?? form.value.total_amount_cny
// form.value.exchange_rate = row.exchange_rate ?? form.value.exchange_rate
form.value.is_domestic_return = row.is_domestic_return ?? form.value.is_domestic_return
form.value.fx_receipt_file = row.fx_receipt_file ?? form.value.fx_receipt_file
form.value.fx_receipt_file_list = row.fx_receipt_file ?? form.value.fx_receipt_file
// const fx_receipt_file_full = []
// if (row.fx_receipt_file_full) {
// fx_receipt_file_full.push(row.fx_receipt_file_full)
// }
// form.value.fx_receipt_file_list = Array.isArray(fx_receipt_file_full)
// ? fx_receipt_file_full
// : form.value.fx_receipt_file_list
}
fetchAccountOptions()
fetchCurrencyOptions()
fetchCountryOptions()
dialogVisible.value = true
}
const handlebtn = (row?: any) => {
ElMessageBox.prompt('你是否确认入账?确认入账请填写入账实时汇率!', '入账', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^(?!\s*$).+/,
inputErrorMessage: '请填写入账实时汇率'
}).then(({ value }) => {
console.log(value)
financeService.confirmReceipt({ id: row.id, exchange_rate: value }).then((res) => {
ElMessage.success(res.message)
getList()
})
})
}
// const handleDelete = () => {
// ElMessageBox.confirm('确定删除吗?', '提示', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
// type: 'warning'
// }).then(() => {
// financeService.deleteReceipt({ id: currentId.value }).then((res) => {
// ElMessage.success(res.message)
// getList()
// })
// })
// }
const handleHistory = (row: any) => {
financeService.getReceiptHistory({ receipt_id: row.id }).then((res) => {
timelineDialogVisible.value = true
approvalRecords.value = res.records || []
// ElMessage.success(res.message)
// getList()
})
// history.push('/admin/finance/receipt_records_history')
}
// function handleUploadSuccess1(response: any) {
// const fileName = response?.data?.file_name || ''
// const fileUrl = response?.data?.file_url || response?.data?.full_url || response?.url || ''
// form.value.fx_receipt_file = [form.value.fx_receipt_file, fileName].filter(Boolean).join(',')
// const list = Array.isArray(form.value.fx_receipt_file_list)
// ? form.value.fx_receipt_file_list
// : []
// list.push(fileUrl)
// form.value.fx_receipt_file_list = [...list]
// ElMessage.success(response.message)
// }
function handleUploadSuccess1(response: any) {
const fileName = response?.data?.file_name || ''
const fileUrl = response?.data?.file_url || response?.data?.full_url || response?.url || ''
const names = Array.isArray(form.value.fx_receipt_file) ? [...form.value.fx_receipt_file] : []
const files = ref({
file_src: '',
file_name: '',
file_full: ''
})
files.value.file_src = fileName
files.value.file_name = response?.data?.original_name || ''
files.value.file_full = response?.data?.file_url || ''
if (fileName) names.push(files.value)
form.value.fx_receipt_file = names
const list = Array.isArray(form.value.fx_receipt_file_list)
? form.value.fx_receipt_file_list
: []
list.push(fileUrl)
form.value.fx_receipt_file_list = [...list]
ElMessage.success(response.message)
}
// 预览项移除(同步预览数组与提交字段)
function removeVoucher(index: number) {
// const list = Array.isArray(form.value.fx_receipt_file_list)
// ? [...form.value.fx_receipt_file_list]
// : []
// if (index < 0 || index >= list.length) return
// list.splice(index, 1)
// form.value.fx_receipt_file_list = list
// const names = (form.value.fx_receipt_file || '').split(',').filter(Boolean)
// if (index < names.length) names.splice(index, 1)
// form.value.fx_receipt_file = names.join(',')
const list = Array.isArray(form.value.fx_receipt_file_list)
? [...form.value.fx_receipt_file_list]
: []
if (index < 0 || index >= list.length) return
list.splice(index, 1)
form.value.fx_receipt_file_list = list
const names = Array.isArray(form.value.fx_receipt_file) ? [...form.value.fx_receipt_file] : []
if (index < names.length) names.splice(index, 1)
form.value.fx_receipt_file = names
}
</script>
<style scoped lang="scss">
:deep(.row-claim-timeout) {
background-color: #ffc9c9 !important;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 6px;
margin-right: 10px;
}
.stocking-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.stocking-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--art-main-bg-color);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-right {
display: flex;
align-items: center;
}
.clickable {
cursor: pointer;
}
// 创建收款记录 - 弹窗样式对齐设计图
.receipt-dialog {
.receipt-form {
.el-form-item {
margin-bottom: 20px;
}
}
.upload-demo {
width: 100%;
display: flex;
align-items: center;
// justify-content: flex-end;
// border: 1px dashed #dcdfe6;
border-radius: 6px;
cursor: pointer;
}
.avatarimg {
width: 60px;
cursor: pointer;
}
.el-button {
width: 200px;
height: 40px;
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;
}
}
}
}
:deep(.receipt-dialog .el-dialog__footer) {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
</style>
+8 -7
View File
@@ -68,7 +68,7 @@
</el-form>
</section>
<div class="table">
<el-button class="reset-button" @click="addfn()" v-if="hasAuth('inquiry#store')"><i class="iconfont-sys">&#xe602;</i> 录单</el-button>
<el-button class="reset-button" @click="addfn()" v-if="hasAuth('inquiry@store')"><i class="iconfont-sys">&#xe602;</i> 录单</el-button>
<ArtTable
ref="tableRef"
row-key="id"
@@ -121,7 +121,7 @@
</el-table-column>
<el-table-column prop="recipient" label="跟单人">
<template #default="scope">
<span style="color:#008477;cursor: pointer;" v-if="scope.row.recipientInfo && hasAuth('inquiry#reallocation')" @click="onFenpei(scope.row,'edit')">
<span style="color:#008477;cursor: pointer;" v-if="scope.row.recipientInfo && hasAuth('inquiry@reallocation')" @click="onFenpei(scope.row,'edit')">
{{scope.row.recipientInfo? scope.row.recipientInfo.name : ''}}
<i class="iconfont-sys">&#xe626;</i>
</span>
@@ -147,31 +147,31 @@
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="260px">
<template #default="scope">
<div class="follow-action fenpei" @click="onFenpei(scope.row,'add')" v-if="scope.row.status!= 2&&hasAuth('inquiry#allocation')">
<div class="follow-action fenpei" @click="onFenpei(scope.row,'add')" v-if="scope.row.status!= 2&&hasAuth('inquiry@allocation')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe852;</i></span
>
分配
</div>
<div class="follow-action detil" @click="onDetail(scope.row)" v-if="hasAuth('inquiry#show')">
<div class="follow-action detil" @click="onDetail(scope.row)" v-if="hasAuth('inquiry@show')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe70f;</i></span
>
详情
</div>
<div class="follow-action" @click="onFollow(scope.row)" v-if="scope.row.status==0&&hasAuth('inquiry#release')">
<div class="follow-action" @click="onFollow(scope.row)" v-if="scope.row.status==0&&hasAuth('inquiry@release')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe712;</i></span
>
发布
</div>
<div class="follow-action edit" @click="onEdit(scope.row)" v-if="hasAuth('inquiry#update')">
<div class="follow-action edit" @click="onEdit(scope.row)" v-if="hasAuth('inquiry@update')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe6bf;</i></span
>
编辑
</div>
<div class="follow-action delete" @click="onDelete(scope.row)" v-if="hasAuth('inquiry#destroy')">
<div class="follow-action delete" @click="onDelete(scope.row)" v-if="hasAuth('inquiry@destroy')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe6d4;</i></span
>
@@ -349,6 +349,7 @@
import { CrmService } from '@/api/crmApi'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
defineOptions({ name: 'InquirySystem' })
const { hasAuth } = useAuth()
const loading = ref(false)
const dialogType = ref('add')

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