Merge remote-tracking branch 'refs/remotes/origin/dev_pro2.0' into dev_宁夏_九泓化工

# Conflicts:
# company.config.ts
# deploy.bat
# vite.config.ts
已添加122个文件
已删除2个文件
已修改254个文件
26353 ■■■■■ 文件已修改
.env.production 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.idea/mom-pro2-before.iml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
FRONTEND_DEVELOPMENT.md 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
JHHG.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WTXC.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
YZFX.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ZSJC.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
commitlint.config.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy.bat 523 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
internal/lint-configs/commitlint-config/index.mjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
internal/vite-config/src/utils/env.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-workspace.yaml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/logos/YZFXLogo.ico 补丁 | 查看 | 原始文档 | blame | 历史
public/logos/YZFXLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/problem.ts 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/ticket.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/visit.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm/autoApprove/index.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm/businessAttach/index.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm/model/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/contract/index.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/invoice/index.ts 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/permission/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/receivable/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/saleQuotation/ai/index.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/salesTarget/index.ts 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/payment/index.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/invoice/ai/index.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/invoice/index.ts 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/order/index.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/request/index.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/sale/order/index.ts 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/record/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/employee/contract/index.ts 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/employee/index.ts 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/leave-type-config/index.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/leave/index.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/overtime/index.ts 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/salary/calculation/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/salary/structure/index.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/schedule/index.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/shift/index.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/item/index.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/unit/index.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/metering/index.ts 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/meteringcheck/index.ts 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/md/item/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/md/item/type/index.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/md/workstation/worker/index.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pd/document/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pd/project/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/feedback/index.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/mps/index.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/processdesign/processparam/index.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/process/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/task/index.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/index.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/process/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorderparamrecord/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/template/item/index.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/batch/index.ts 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/materialstock/index.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productissue/line/index.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/returnissue/line/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/returnvendor/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/supplier/index.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/supplierApply/index.ts 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/supplierCertificate/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/tender/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/company-config/index.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/database/index.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/dept/index.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/post/index.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/storage/index.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/user/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/bootstrap.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/form-create/helpers.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/upload/file-upload.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/upload/image-upload.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/upload/use-upload.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/icons/src/lucide.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/shadcn-ui/src/components/segmented/segmented.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-erp-enum.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-wms-enum.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/basic/header/header.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/basic/layout.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/database-backup/database-backup.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/database-backup/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/blocks/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/locales/src/langs/en-US/preferences.json 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/locales/src/langs/zh-CN/preferences.json 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/preferences.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/guard.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/crm.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/mes.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/company-config.ts 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/types/app.d.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/company-config.ts 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/index.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/problem/data.ts 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/problem/index.vue 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/problem/modules/form.vue 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/detail.vue 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/data.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/index.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/import-form.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/data.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/index.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/modules/import-form.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/businessAttach/data.ts 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/businessAttach/index.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/businessAttach/modules/form.vue 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/index.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/modules/category-draggable-model.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/processInstance/detail/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/task/done/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/task/done/index.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/receivable-audit-list.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/business/detail/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/clue/detail/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contact/detail/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/components/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/approve/index.vue 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/modules/import-form.vue 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/data.ts 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/detail/approve/index.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/detail/data.ts 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/detail/index.vue 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/detail/modules/info.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/index.vue 259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/modules/form.vue 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/permission/modules/list.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/permission/modules/transfer-form.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/components/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/data.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/detail/approve/index.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/detail/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/index.vue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/modules/form.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/plan/data.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/plan/modules/form.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/ocr-upload.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/analysis/chart-options.ts 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/analysis/data.ts 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/analysis/index.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/data.ts 351 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/index.vue 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/modules/form.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/performance/chartOptions.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/performance/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/alert-panel.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/data.ts 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/detail/approve/index.vue 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/index.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/modules/form.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/modules/item-form.vue 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/modules/process-select-modal.vue 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/modules/purchase-in-select.vue 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/modules/sale-return-select.vue 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/data.ts 250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/detail/approve/index.vue 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/index.vue 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/form.vue 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/ocr-upload.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/purchase-order-select.vue 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/data.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/detail/index.vue 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/index.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/modules/import-form.vue 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/detail/index.vue 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/index.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/modules/form.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/modules/import-form.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/data.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/detail/index.vue 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/import-form.vue 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/item-form.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/print.vue 459 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/exception/detail/approve/index.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/modules/import-form.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/components/select.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/data.ts 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/index.vue 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/modules/form.vue 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/modules/import-form.vue 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/modules/terminate.vue 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/data.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/index.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/contract-list.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/detail.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/import-form.vue 161 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/detail/approve/index.vue 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/index.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/modules/form.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/data.ts 320 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/detail/approve/index.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/index.vue 200 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/modules/form.vue 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/resignation/detail/approve/index.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/calculation/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/calculation/index.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/leave-type-config/data.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/leave-type-config/index.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/leave-type-config/modules/form.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/structure/data.ts 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/structure/index.vue 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/structure/modules/form.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/schedule/data.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/schedule/index.vue 743 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/shift/data.ts 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/shift/index.vue 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/shift/modules/form.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/transfer/detail/approve/index.vue 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/type/components/tree.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/type/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/components/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/components/select-dialog.vue 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/components/select.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/data.ts 437 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/index.vue 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/modules/form.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/modules/import-form.vue 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/meteringcheck/data.ts 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/meteringcheck/index.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/meteringcheck/modules/form.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/product-bom-select-dialog.vue 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/product-bom-select.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select-dialog.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/data.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/index.vue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/modules/form.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/modules/product-media-list.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/type/data.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/type/index.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/type/modules/import-form.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/workstation/index.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/workstation/modules/form.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/workstation/workshop/modules/form.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/archive/modules/detail.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/detail/index.vue 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/index.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/document-audit-form.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/process-select-modal.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/workflow-timeline.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/data.ts 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/detail/index.vue 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/index.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/batch-feedback.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/form.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/param-state.ts 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/data.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/data.ts 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/edit/index.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/index.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/modules/schedule-form.vue 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/modules/task-form.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/modules/task-list.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/modules/quick-feedback.vue 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/components/select-dialog.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/components/select.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/index.vue 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/form.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-form.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/parameter/index.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/parameter/modules/process-link-dialog.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/index.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/modules/param-manage.vue 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/data.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/form.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/process-form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/product-form.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/workbench.vue 545 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/data.ts 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/process-trace-list.vue 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/purchase-source-list.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/sales-target-list.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/trace-detail.vue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/export-range-modal.vue 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/data.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/data.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/data.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/rqc/data.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/rqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/template/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/TaskListPanel.vue 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/FeedbackTab.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/ProductIssueLineFormModal.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/TaskDetailTab.vue 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/index.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/types.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/enums.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/statistics/index.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplier/data.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplier/index.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplier/modules/import-form.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/data.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/detail/index.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/index.vue 188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/modules/form.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/modules/process-select-modal.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierCertificate/data.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierCertificate/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierCertificate/modules/form.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/data.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/detail.vue 161 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/modules/form.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/dept/data.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/dept/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/dept/modules/import-form.vue 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/post/data.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/post/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/post/modules/import-form.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/user/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/arrivalnotice/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/arrivalnotice/modules/po-select-dialog.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/config/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/itemreceipt/modules/an-select-dialog.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/import-form.vue 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/stock-adjust-modal.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/components/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/components/select-dialog.vue 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/components/select.vue 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/data.ts 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/data.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/data.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/modules/line-form.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/modules/line-list.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/modules/detail-form.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/modules/form.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/plan/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/plan/modules/param-form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/area/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/location/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.production
@@ -3,9 +3,9 @@
VITE_BASE=/
# è¯·æ±‚路径
VITE_BASE_URL=http://36.213.180.217:9500
VITE_BASE_URL=http://36.213.90.123:9501
# æŽ¥å£åœ°å€
VITE_GLOB_API_URL=http://36.213.180.217:9500/admin-api
VITE_GLOB_API_URL=http://36.213.90.123:9501/admin-api
# æ–‡ä»¶ä¸Šä¼ ç±»åž‹ï¼šserver - åŽç«¯ä¸Šä¼ ï¼Œ client - å‰ç«¯ç›´è¿žä¸Šä¼ ï¼Œä»…支持S3服务
VITE_UPLOAD_TYPE=server
.idea/mom-pro2-before.iml
@@ -7,6 +7,8 @@
      <excludeFolder url="file://$MODULE_DIR$/.cache" />
      <excludeFolder url="file://$MODULE_DIR$/dist" />
      <excludeFolder url="file://$MODULE_DIR$/build" />
      <excludeFolder url="file://$MODULE_DIR$/.agents" />
      <excludeFolder url="file://$MODULE_DIR$/.codex" />
    </content>
    <orderEntry type="inheritedJdk" />
    <orderEntry type="sourceFolder" forTests="false" />
FRONTEND_DEVELOPMENT.md
@@ -1020,7 +1020,68 @@
---
## åã€ç›¸å…³èµ„源
## åã€Git æäº¤è§„范
### 10.1 æäº¤æ ¼å¼
```
<type>: <subject>
<body>
```
- **type** å’Œ **subject** ä¹‹é—´å¿…须有一个空格(`: `)
- **subject** å¿…填,简短描述变更内容
- **body** å¯é€‰ï¼Œä¸ºç©ºæ—¶è·³è¿‡å³å¯
### 10.2 æ”¯æŒçš„ Type
| type       | è¯´æ˜Ž               |
| ---------- | ------------------ |
| `feat`     | æ–°åŠŸèƒ½             |
| `fix`      | ä¿®å¤ Bug           |
| `docs`     | æ–‡æ¡£ä¿®æ”¹           |
| `style`    | ä»£ç æ ¼å¼ï¼ˆä¸å½±å“é€»è¾‘) |
| `refactor` | ä»£ç é‡æž„           |
| `perf`     | æ€§èƒ½ä¼˜åŒ–           |
| `test`     | æ·»åŠ æµ‹è¯•ä»£ç        |
| `build`    | ä¾èµ–/构建修改      |
| `ci`       | CI/CD é›†æˆ         |
| `chore`    | æ—¥å¸¸äº‹åŠ¡           |
| `revert`   | ç‰ˆæœ¬å›žé€€           |
| `types`    | ç±»åž‹å®šä¹‰ä¿®æ”¹       |
### 10.3 ç¤ºä¾‹
```bash
# æœ€ç®€å•(只有主题)
feat: æ·»åŠ ç‰©æ–™ç±»åž‹ç®¡ç†é¡µé¢
# å¸¦è¯¦ç»†è¯´æ˜Ž
fix: ä¿®å¤å®¡æ‰¹æµç¨‹æ•°æ®ä¸¢å¤±
原因是后端接口返回格式变化,调整了数据解析逻辑。
# å¸¦åºå·åˆ—表
refactor: é‡æž„请求拦截器
1. æå–公共错误处理逻辑
2. ç»Ÿä¸€ Token åˆ·æ–°æœºåˆ¶
```
### 10.4 è‡ªåŠ¨æ ¡éªŒ
项目已配置 commitlint,每次提交时会自动校验格式。如果格式不符合规范,Git ä¼šæ‹’绝提交并提示错误。
也可以使用交互式提交:
```bash
pnpm commit
```
---
## åä¸€ã€ç›¸å…³èµ„源
- [Vue 3 æ–‡æ¡£](https://vuejs.org/)
- [Ant Design Vue æ–‡æ¡£](https://antdv.com/)
JHHG.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=JHHG"
set "REMOTE_USER=root"
set "REMOTE_HOST=42.63.70.90"
set "REMOTE_PORT=22"
set "PASSWORD=Nxjhhg!@##@!"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/home/vue/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [WARN] sshpass not found.
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=%SSHPASS_DIR%;%PATH%"
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto ERROR_EXIT
)
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
echo.
echo [OK] Build completed.
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo [INFO] Checking unzip...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
WTXC.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=WTXC"
set "REMOTE_USER=root"
set "REMOTE_HOST=42.63.71.140"
set "REMOTE_PORT=22"
set "PASSWORD=Nxwtxc123."
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/home/vue/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [WARN] sshpass not found.
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=%SSHPASS_DIR%;%PATH%"
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto ERROR_EXIT
)
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
echo.
echo [OK] Build completed.
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo [INFO] Checking unzip...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
YZFX.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=YZFX"
set "REMOTE_USER=root"
set "REMOTE_HOST=42.63.70.231"
set "REMOTE_PORT=22"
set "PASSWORD=Nxyzfxxcl1@"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/home/vue/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [WARN] sshpass not found.
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=%SSHPASS_DIR%;%PATH%"
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto ERROR_EXIT
)
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
echo.
echo [OK] Build completed.
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo [INFO] Checking unzip...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
ZSJC.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=ZSJC"
set "REMOTE_USER=root"
set "REMOTE_HOST=116.172.73.239"
set "REMOTE_PORT=22"
set "PASSWORD=ecs-QGY8ecs-QGY8"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/home/vue/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [WARN] sshpass not found.
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=%SSHPASS_DIR%;%PATH%"
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto ERROR_EXIT
)
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
echo.
echo [OK] Build completed.
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo [INFO] Checking unzip...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
commitlint.config.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2 @@
// å¼•用内部 commitlint é…ç½®
export { default } from './internal/lint-configs/commitlint-config/index.mjs';
company.config.ts
@@ -5,6 +5,8 @@
  logo: string;
  /** ç½‘页标题图标路径,相对于 public ç›®å½• */
  favicon: string;
  /** API æŽ¥å£åœ°å€ */
  apiUrl: string;
}
/**
@@ -15,5 +17,58 @@
 * ç¤ºä¾‹ï¼šset "COMPANY=TEST"
 */
export const companies: Record<string, CompanyConfig> = {
  JHHG: { name: '九泓化工', logo: '/logos/TEST.png', favicon: '/logos/TEST.ico' },
  TEST: {
    name: '测试公司',
    logo: '/logos/TEST.png',
    favicon: '/logos/TEST.ico',
    apiUrl: 'http://36.213.90.123:9501/admin-api',
  },
  JHHG: {
    name: '宁夏九泓化工',
    logo: '/logos/TEST.png',
    favicon: '/logos/TEST.ico',
    apiUrl: 'http://42.63.70.90:9001/admin-api',
  },
  YZFX: {
    name: '宁夏英泽防锈',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://42.63.70.231:9001/admin-api',
  },
  WTXC: {
    name: '宁夏万通新材',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://42.63.71.140:9001/admin-api',
  },
  YSJX: {
    name: '宁夏玉山机械',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://42.63.71.140:9004/admin-api',
  },
  XLZB: {
    name: '宁夏协力装备',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://42.63.71.140:9004/admin-api',
  },
  ZSJC: {
    name: '宁夏中盛建材科技有限公司',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://116.172.73.239:9001/admin-api',
  },
  KYZN: {
    name: '康意智能',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9004/admin-api',
  },
  CHDL: {
    name: '长辉电力',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9006/admin-api',
  },
};
deploy.bat
@@ -1,170 +1,451 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
setlocal EnableExtensions EnableDelayedExpansion
:: ================= Config =================
set "COMPANY=JHHG"
set "USER=root"
set "PASS=Xdrjkj@10086#"
set "IP=36.213.180.217"
set "PORT=65022"
set "DIST_DIR=dist"
set "REMOTE_DIR=/data/project/mom/vue/test"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10"
:: ===========================================
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
echo ==========================================
echo        Vue Dist Auto Deploy Script
echo ==========================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=TEST"
set "REMOTE_USER=root"
set "REMOTE_HOST=36.213.90.123"
set "REMOTE_PORT=65022"
set "PASSWORD=Xdrjkj@10086#"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/data/project/mom/vue/test/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
:: Check if sshpass is available
set "SSHPASS_DIR=%~dp0bin"
if exist "!SSHPASS_DIR!\sshpass.exe" (
    set "PATH=!SSHPASS_DIR!;%PATH%"
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if %errorlevel% equ 0 goto :check_node
echo [WARN] sshpass not found, trying to install...
echo.
where bash >nul 2>&1
if %errorlevel% equ 0 (
    echo [INFO] Git Bash detected, trying pacman install...
    bash -c "pacman -S --noconfirm sshpass" 2>nul
    if !errorlevel! equ 0 (
        echo [OK] sshpass installed via pacman!
        goto :check_node
    )
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
if not exist "!SSHPASS_DIR!" mkdir "!SSHPASS_DIR!"
echo [WARN] sshpass not found.
if exist "!SSHPASS_DIR!\sshpass.exe" (
    set "PATH=!SSHPASS_DIR!;%PATH%"
    echo [OK] sshpass found at: !SSHPASS_DIR!
    goto :check_node
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='!SSHPASS_DIR!\sshpass.exe'; Write-Host \"Downloading to: $out\"; try { Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing; exit 0 } catch { Write-Host $_.Exception.Message; exit 1 }"
if !errorlevel! neq 0 (
    echo [ERROR] Failed to download sshpass!
    echo [HINT] Please install sshpass manually or use Git Bash: pacman -S sshpass
    goto :error_exit
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=!SSHPASS_DIR!;%PATH%"
echo [OK] sshpass downloaded to: !SSHPASS_DIR!
echo.
set "PATH=%SSHPASS_DIR%;%PATH%"
:check_node
:: Check if pnpm is available
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if %errorlevel% neq 0 (
    echo [ERROR] pnpm not found! Please install pnpm first.
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto :error_exit
    goto ERROR_EXIT
)
:: Check if node_modules exists
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo [INFO] node_modules not found, running pnpm install...
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if !errorlevel! neq 0 (
        echo [ERROR] pnpm install failed!
        goto :error_exit
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
)
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
:: Check if dist exists and ask user
if exist "%DIST_DIR%" (
    echo [WARN] dist directory already exists.
    set /p "REBUILD=Do you want to rebuild? (Y/N, default Y): "
    if /i "!REBUILD!"=="N" (
        echo [INFO] Skip build, using existing dist
        goto :upload
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
:: Build the project
echo [STEP 1/3] Building project for %COMPANY%...
echo [INFO] Running: pnpm run build
echo.
set "COMPANY=!COMPANY!" && call pnpm run build
if !errorlevel! neq 0 (
    echo [ERROR] Build failed! Check your project configuration.
    goto :error_exit
)
echo [OK] Build completed!
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
:upload
if not exist "%DIST_DIR%" (
    echo [ERROR] Dist directory not found: %DIST_DIR%
    echo [HINT] Build may have failed or output directory is incorrect.
    goto :error_exit
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
:: Count files and calculate size
set "FILE_COUNT=0"
set "TOTAL_SIZE=0"
for /r "%DIST_DIR%" %%F in (*) do (
    set /a "FILE_COUNT+=1"
    set /a "TOTAL_SIZE+=%%~zF"
)
set /a "TOTAL_SIZE_MB=TOTAL_SIZE/1024/1024"
echo [INFO] Dist directory: %DIST_DIR%
echo [INFO] Files count: %FILE_COUNT%
echo [INFO] Total size: %TOTAL_SIZE_MB% MB
echo [INFO] Target: %USER%@%IP%:%PORT%
echo [INFO] Remote dir: %REMOTE_DIR%
echo.
echo [OK] Build completed.
:: Optional: Ask for confirmation before upload
set /p "CONFIRM=Proceed with upload? (Y/N, default Y): "
if /i "!CONFIRM!"=="N" (
    echo [INFO] Upload cancelled by user.
    goto :end
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
:: Upload dist directory
echo [STEP 2/3] Uploading dist directory...
sshpass -p "%PASS%" scp -P %PORT% %SSH_OPTS% -r "%DIST_DIR%" %USER%@%IP%:%REMOTE_DIR%/
if %errorlevel% neq 0 (
    echo [ERROR] Upload failed! Check network or credentials.
    goto :error_exit
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed!
echo.
:: Optional: Remote restart/cleanup (uncomment if needed)
:: echo [STEP 3/3] Restarting remote service...
:: sshpass -p "%PASS%" ssh -p %PORT% %SSH_OPTS% %USER%@%IP% "cd %REMOTE_DIR% && npm run restart"
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo ==========================================
echo [SUCCESS] Deploy completed!
echo ==========================================
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo Summary:
echo   - Build: Completed
echo   - Upload: %FILE_COUNT% files (%TOTAL_SIZE_MB% MB)
echo   - Target: %USER%@%IP%:%PORT%%REMOTE_DIR%
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
goto :end
:error_exit
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo ==========================================
echo [FAILED] Deploy failed!
echo ==========================================
echo [INFO] Checking unzip...
:end
pause
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
internal/lint-configs/commitlint-config/index.mjs
@@ -90,7 +90,7 @@
     * ^^^^^^^^^^^^^^ empty line.
     * - Something here
     */
    'body-leading-blank': [2, 'always'],
    'body-leading-blank': [0],
    /**
     * type[scope]: [function] description
     *
internal/vite-config/src/utils/env.ts
@@ -38,7 +38,7 @@
  match = 'VITE_GLOB_',
  confFiles = getConfFiles(),
) {
  let envConfig = {};
  let envConfig: Record<string, string> = {};
  for (const confFile of confFiles) {
    try {
@@ -60,6 +60,13 @@
      Reflect.deleteProperty(envConfig, key);
    }
  });
  // åˆå¹¶è¿›ç¨‹çŽ¯å¢ƒå˜é‡ï¼Œä½¿ vite.config.ts æŒ‰å…¬å¸æ³¨å…¥çš„ VITE_GLOB_API_URL èƒ½è¿›å…¥ _app-config æ–‡ä»¶
  Object.keys(process.env).forEach((key) => {
    const value = process.env[key];
    if (reg.test(key) && value) {
      envConfig[key] = value;
    }
  });
  return envConfig as T;
}
package.json
@@ -18,6 +18,7 @@
  "scripts": {
    "build": "pnpm vite build --mode production",
    "build:analyze": "pnpm vite build --mode analyze",
    "commit": "czg",
    "dev": "pnpm vite --mode development",
    "preview": "vite preview",
    "typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vue-tsc --noEmit --skipLibCheck --incremental --tsBuildInfoFile node_modules/.cache/vue-tsc/tsconfig.tsbuildinfo"
@@ -80,11 +81,13 @@
    "@vben/tailwind-config": "workspace:*",
    "@vben/tsconfig": "workspace:*",
    "@vben/vite-config": "workspace:*",
    "cross-env": "catalog:",
    "jiti": "^2.7.0",
    "tsdown": "^0.22.3",
    "tsx": "^4.22.4",
    "typescript": "catalog:",
    "vite": "catalog:"
    "vite": "catalog:",
    "vue-tsc": "catalog:"
  },
  "packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38"
}
pnpm-lock.yaml
@@ -231,6 +231,9 @@
    cropperjs:
      specifier: ^1.6.2
      version: 1.6.2
    cross-env:
      specifier: ^10.1.0
      version: 10.1.0
    crypto-js:
      specifier: ^4.2.0
      version: 4.2.0
@@ -504,6 +507,9 @@
    vue-tippy:
      specifier: ^6.7.1
      version: 6.7.1
    vue-tsc:
      specifier: ^3.3.9
      version: 3.3.9
    vue3-print-nb:
      specifier: ^0.1.4
      version: 0.1.4
@@ -693,12 +699,15 @@
      '@vben/vite-config':
        specifier: workspace:*
        version: link:internal/vite-config
      cross-env:
        specifier: 'catalog:'
        version: 10.1.0
      jiti:
        specifier: ^2.7.0
        version: 2.7.0
      tsdown:
        specifier: ^0.22.3
        version: 0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))
        version: 0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
      tsx:
        specifier: ^4.22.4
        version: 4.22.4
@@ -708,6 +717,9 @@
      vite:
        specifier: 'catalog:'
        version: 8.0.10(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
      vue-tsc:
        specifier: 'catalog:'
        version: 3.3.9(typescript@6.0.3)
  internal/lint-configs/commitlint-config:
    dependencies:
@@ -2552,6 +2564,9 @@
  '@emotion/unitless@0.8.1':
    resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
  '@epic-web/invariant@1.0.0':
    resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
  '@es-joy/jsdoccomment@0.84.0':
    resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==}
@@ -4754,6 +4769,9 @@
  '@vue/devtools-shared@8.1.3':
    resolution: {integrity: sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==}
  '@vue/language-core@3.3.9':
    resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==}
  '@vue/reactivity@3.5.38':
    resolution: {integrity: sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==}
@@ -4914,6 +4932,9 @@
  ajv@8.20.0:
    resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
  alien-signals@3.2.1:
    resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
  ansi-regex@5.0.1:
    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -5527,6 +5548,11 @@
  cropperjs@1.6.2:
    resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
  cross-env@10.1.0:
    resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
    engines: {node: '>=20'}
    hasBin: true
  cross-spawn@7.0.6:
    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -9595,6 +9621,12 @@
    peerDependencies:
      vue: ^3.2.0
  vue-tsc@3.3.9:
    resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==}
    hasBin: true
    peerDependencies:
      typescript: '>=5.0.0'
  vue-types@3.0.0:
    resolution: {integrity: sha512-xzq2D7zLGbzn3bPQ0CWWYx4W78JWdGYZAFfRkzxz1aTm9DaNoH0qxYDiLXBofBEQ5X78l32uIq/G9INQ2E8X/Q==}
    engines: {node: '>=10.15.0'}
@@ -10976,6 +11008,8 @@
  '@emotion/hash@0.9.2': {}
  '@emotion/unitless@0.8.1': {}
  '@epic-web/invariant@1.0.0': {}
  '@es-joy/jsdoccomment@0.84.0':
    dependencies:
@@ -12903,6 +12937,16 @@
  '@vue/devtools-shared@8.1.3': {}
  '@vue/language-core@3.3.9':
    dependencies:
      '@volar/language-core': 2.4.28
      '@vue/compiler-dom': 3.5.38
      '@vue/shared': 3.5.38
      alien-signals: 3.2.1
      muggle-string: 0.4.1
      path-browserify: 1.0.1
      picomatch: 4.0.4
  '@vue/reactivity@3.5.38':
    dependencies:
      '@vue/shared': 3.5.38
@@ -13069,6 +13113,8 @@
      fast-uri: 3.1.2
      json-schema-traverse: 1.0.0
      require-from-string: 2.0.2
  alien-signals@3.2.1: {}
  ansi-regex@5.0.1: {}
@@ -13722,6 +13768,11 @@
  croner@10.0.1: {}
  cropperjs@1.6.2: {}
  cross-env@10.1.0:
    dependencies:
      '@epic-web/invariant': 1.0.0
      cross-spawn: 7.0.6
  cross-spawn@7.0.6:
    dependencies:
@@ -16668,7 +16719,7 @@
  robust-predicates@3.0.3: {}
  rolldown-plugin-dts@0.26.0(rolldown@1.1.2)(typescript@6.0.3):
  rolldown-plugin-dts@0.26.0(rolldown@1.1.2)(typescript@6.0.3)(vue-tsc@3.3.9(typescript@6.0.3)):
    dependencies:
      '@babel/generator': 8.0.0
      '@babel/helper-validator-identifier': 8.0.2
@@ -16681,6 +16732,7 @@
      rolldown: 1.1.2
    optionalDependencies:
      typescript: 6.0.3
      vue-tsc: 3.3.9(typescript@6.0.3)
    transitivePeerDependencies:
      - oxc-resolver
@@ -17573,7 +17625,7 @@
      minimist: 1.2.8
      strip-bom: 3.0.0
  tsdown@0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)):
  tsdown@0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3)):
    dependencies:
      ansis: 4.3.1
      cac: 7.0.0
@@ -17584,7 +17636,7 @@
      obug: 2.1.3
      picomatch: 4.0.4
      rolldown: 1.1.2
      rolldown-plugin-dts: 0.26.0(rolldown@1.1.2)(typescript@6.0.3)
      rolldown-plugin-dts: 0.26.0(rolldown@1.1.2)(typescript@6.0.3)(vue-tsc@3.3.9(typescript@6.0.3))
      semver: 7.8.5
      tinyexec: 1.2.4
      tinyglobby: 0.2.17
@@ -18092,6 +18144,12 @@
      tippy.js: 6.3.7
      vue: 3.5.38(typescript@6.0.3)
  vue-tsc@3.3.9(typescript@6.0.3):
    dependencies:
      '@volar/typescript': 2.4.28
      '@vue/language-core': 3.3.9
      typescript: 6.0.3
  vue-types@3.0.0(vue@3.5.38(typescript@6.0.3)):
    dependencies:
      is-plain-object: 3.0.1
pnpm-workspace.yaml
@@ -193,7 +193,7 @@
  vue-json-pretty: ^2.6.0
  vue-router: ^5.1.0
  vue-tippy: ^6.7.1
  vue-tsc: ^3.3.3
  vue-tsc: ^3.3.9
  vue3-print-nb: ^0.1.4
  vue3-signature: ^0.4.4
  vuedraggable: ^4.1.0
public/logos/YZFXLogo.ico
public/logos/YZFXLogo.png
src/api/aftersales/problem.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,103 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AftersalesProblemApi {
  /** å”®åŽé—®é¢˜ */
  export interface Problem {
    id?: number;
    no?: string;
    title?: string;
    sourceTicketId?: number;
    sourceTicketNo?: string;
    customerId?: number;
    customerName?: string;
    itemId?: number;
    itemCode?: string;
    itemName?: string;
    batchId?: number;
    batchCode?: string;
    issueType?: number;
    issueCategory?: string;
    severityLevel?: number;
    description?: string;
    /** æ ¹å› åˆ†ç±»: 1-设计 2-物料 3-工艺 4-装配 5-运输 6-包装 7-使用 8-软件 9-其他 */
    rootCauseCategory?: number;
    rootCause?: string;
    solution?: string;
    /** çŠ¶æ€: 0-草稿 1-已发布 2-已停用 */
    status?: number;
    ownerUserId?: number;
    ownerUserName?: string;
    publishTime?: string;
    remark?: string;
    creator?: string;
    createTime?: string;
    updater?: string;
    updateTime?: string;
    /** é™„ä»¶ blobId åˆ—表(保存时传入) */
    blobIds?: number[];
    /** é™„件列表(详情返回) */
    attachmentList?: { id: number; name?: string; url?: string }[];
  }
}
/** æ–°å¢žå”®åŽé—®é¢˜ï¼ˆè‰ç¨¿ï¼‰ */
export function createProblem(data: AftersalesProblemApi.Problem) {
  return requestClient.post<number>('/aftersales/problem/create', data);
}
/** æ›´æ–°å”®åŽé—®é¢˜ï¼ˆä»…草稿) */
export function updateProblem(data: AftersalesProblemApi.Problem) {
  return requestClient.put<boolean>('/aftersales/problem/update', data);
}
/** åˆ é™¤å”®åŽé—®é¢˜ï¼ˆä»…草稿) */
export function deleteProblem(id: number) {
  return requestClient.delete<boolean>('/aftersales/problem/delete', {
    params: { id },
  });
}
/** èŽ·å–å”®åŽé—®é¢˜è¯¦æƒ… */
export function getProblem(id: number) {
  return requestClient.get<AftersalesProblemApi.Problem>(
    `/aftersales/problem/get?id=${id}`,
  );
}
/** åˆ†é¡µæŸ¥è¯¢å”®åŽé—®é¢˜ */
export function getProblemPage(params: PageParam) {
  return requestClient.get<PageResult<AftersalesProblemApi.Problem>>(
    '/aftersales/problem/page',
    { params },
  );
}
/** å‘布问题(草稿/停用 -> å·²å‘布) */
export function publishProblem(id: number) {
  return requestClient.put<boolean>('/aftersales/problem/publish', null, {
    params: { id },
  });
}
/** åœç”¨é—®é¢˜ï¼ˆå·²å‘布 -> åœç”¨ï¼‰ */
export function disableProblem(id: number) {
  return requestClient.put<boolean>('/aftersales/problem/disable', null, {
    params: { id },
  });
}
/** å‚考检索(新工单受理,只返回已发布) */
export function referenceProblem(params: {
  itemId?: number;
  issueCategory?: string;
  issueType?: number;
  rootCauseCategory?: number;
  keyword?: string;
}) {
  return requestClient.get<AftersalesProblemApi.Problem[]>(
    '/aftersales/problem/reference',
    { params },
  );
}
src/api/aftersales/ticket.ts
@@ -46,6 +46,14 @@
    repairId?: number;
    resolvedTime?: string;
    closedTime?: string;
    /** å›žè®¿çŠ¶æ€: 0-待回访 1-已回访通过 */
    visitStatus?: number;
    lastVisitTime?: string;
    lastVisitSatisfied?: boolean;
    lastVisitRecurred?: boolean;
    /** å½’档沉淀的问题 */
    problemId?: number;
    problemNo?: string;
    remark?: string;
    creator?: string;
    createTime?: string;
src/api/aftersales/visit.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AftersalesVisitApi {
  /** å›žè®¿è®°å½• */
  export interface Visit {
    id?: number;
    ticketId?: number;
    visitType?: number;
    visitorUserId?: number;
    visitorUserName?: string;
    content?: string;
    satisfied?: boolean;
    recurred?: boolean;
    visitTime?: string;
    remark?: string;
    creator?: string;
    createTime?: string;
    /** é™„件列表(详情返回) */
    attachmentList?: { id: number; name?: string; url?: string }[];
  }
  /** ç™»è®°å›žè®¿å…¥å‚ */
  export interface CreateVisit {
    ticketId: number;
    visitType: number;
    content?: string;
    satisfied: boolean;
    recurred: boolean;
    remark?: string;
    /** é™„ä»¶ blobId åˆ—表(保存时传入) */
    blobIds?: number[];
  }
}
/** ç™»è®°å”®åŽå›žè®¿ */
export function createVisit(data: AftersalesVisitApi.CreateVisit) {
  return requestClient.post<number>('/aftersales/visit/create', data);
}
/** èŽ·å–å›žè®¿è¯¦æƒ… */
export function getVisit(id: number) {
  return requestClient.get<AftersalesVisitApi.Visit>(
    `/aftersales/visit/get?id=${id}`,
  );
}
/** èŽ·å–æŸå·¥å•çš„å›žè®¿è®°å½•åˆ—è¡¨ */
export function getVisitListByTicketId(ticketId: number) {
  return requestClient.get<AftersalesVisitApi.Visit[]>(
    `/aftersales/visit/list?ticketId=${ticketId}`,
  );
}
/** åˆ†é¡µæŸ¥è¯¢å›žè®¿è®°å½• */
export function getVisitPage(params: PageParam) {
  return requestClient.get<PageResult<AftersalesVisitApi.Visit>>(
    '/aftersales/visit/page',
    { params },
  );
}
src/api/bpm/autoApprove/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
import { requestClient } from '#/api/request';
export namespace BpmAutoApproveApi {
  /** æµç¨‹å…å®¡æ‰¹é…ç½® */
  export interface AutoApproveConfig {
    id: number;
    /** æµç¨‹å®šä¹‰çš„ Key */
    processDefinitionKey: string;
    /** æ˜¯å¦å…å®¡æ‰¹ï¼štrue-提交后系统自动通过,false-正常人工审批 */
    autoApprove: boolean;
    /** å¤‡æ³¨ */
    remark?: string;
    createTime: string;
  }
}
/** èŽ·å–å…¨éƒ¨æµç¨‹å…å®¡æ‰¹é…ç½® */
export async function getAutoApproveList() {
  return requestClient.get<BpmAutoApproveApi.AutoApproveConfig[]>(
    '/bpm/process-auto-approve/list',
  );
}
/** èŽ·å¾—å…¨å±€å…å®¡æ‰¹æ€»å¼€å…³çŠ¶æ€ */
export async function getGlobalAutoApprove() {
  return requestClient.get<boolean>('/bpm/process-auto-approve/global');
}
/** è®¾ç½®å…¨å±€å…å®¡æ‰¹æ€»å¼€å…³ï¼šå¼€å¯åŽæ‰€æœ‰æµç¨‹ä¸€å¾‹å…å®¡æ‰¹ */
export async function updateGlobalAutoApprove(
  autoApprove: boolean,
  remark?: string,
) {
  const params = new URLSearchParams({ autoApprove: String(autoApprove) });
  if (remark) {
    params.append('remark', remark);
  }
  return requestClient.put<boolean>(
    `/bpm/process-auto-approve/update-global?${params.toString()}`,
  );
}
/** æŒ‰æµç¨‹å®šä¹‰çš„ Key è®¾ç½®å…å®¡æ‰¹å¼€å…³ï¼ˆä¸å­˜åœ¨åˆ™æ–°å¢žã€å­˜åœ¨åˆ™æ›´æ–°ï¼‰ */
export async function updateAutoApproveByKey(
  processDefinitionKey: string,
  autoApprove: boolean,
  remark?: string,
) {
  const params = new URLSearchParams({
    processDefinitionKey,
    autoApprove: String(autoApprove),
  });
  if (remark) {
    params.append('remark', remark);
  }
  return requestClient.put<boolean>(
    `/bpm/process-auto-approve/update-by-key?${params.toString()}`,
  );
}
src/api/bpm/businessAttach/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace BpmBusinessAttachApi {
  /** ä¸šåŠ¡é™„ä»¶é…ç½® */
  export interface BusinessAttach {
    id: number;
    processDefinitionKey: string;
    businessType?: string;
    componentPath: string;
    status: number;
    remark?: string;
    createTime?: string;
  }
}
/** æŸ¥è¯¢ä¸šåŠ¡é™„ä»¶é…ç½®åˆ†é¡µ */
export async function getBusinessAttachPage(params: PageParam) {
  return requestClient.get<PageResult<BpmBusinessAttachApi.BusinessAttach>>(
    '/bpm/business-attach/page',
    { params },
  );
}
/** æŸ¥è¯¢ä¸šåŠ¡é™„ä»¶é…ç½®è¯¦æƒ… */
export async function getBusinessAttach(id: number) {
  return requestClient.get<BpmBusinessAttachApi.BusinessAttach>(
    `/bpm/business-attach/get?id=${id}`,
  );
}
/** æ–°å¢žä¸šåŠ¡é™„ä»¶é…ç½® */
export async function createBusinessAttach(
  data: BpmBusinessAttachApi.BusinessAttach,
) {
  return requestClient.post<number>('/bpm/business-attach/create', data);
}
/** ä¿®æ”¹ä¸šåŠ¡é™„ä»¶é…ç½® */
export async function updateBusinessAttach(
  data: BpmBusinessAttachApi.BusinessAttach,
) {
  return requestClient.put<boolean>('/bpm/business-attach/update', data);
}
/** åˆ é™¤ä¸šåŠ¡é™„ä»¶é…ç½® */
export async function deleteBusinessAttach(id: number) {
  return requestClient.delete<boolean>(
    `/bpm/business-attach/delete?id=${id}`,
  );
}
src/api/bpm/model/index.ts
@@ -20,6 +20,8 @@
    createTime: string;
    bpmnXml: string;
    startUsers?: UserInfo[];
    /** æ˜¯å¦å…å®¡æ‰¹ï¼ˆç”±é¡µé¢æ ¹æ®æµç¨‹å…å®¡æ‰¹é…ç½®åˆå¹¶ï¼ŒéžåŽç«¯è¿”回字段) */
    autoApprove?: boolean;
  }
  /** æµç¨‹å®šä¹‰ */
src/api/crm/contract/index.ts
@@ -28,6 +28,8 @@
    totalPrice: number;
    depositPrice?: number; // å®šé‡‘金额,单位:元
    totalReceivablePrice: number;
    totalInvoicePrice?: number; // å·²å¼€ç¥¨é‡‘额(含审批中),单位:元
    unInvoicePrice?: number; // æœªå¼€ç¥¨é‡‘额,单位:元
    signContactId: number;
    signContactName?: string;
    signUserId: number;
@@ -158,3 +160,23 @@
export function generateSaleOrder(id: number) {
  return requestClient.post<number>('/crm/contract/generate-sale-order', null, { params: { id } });
}
/** åˆåŒå¯¼å…¥ç»“æžœ */
export interface ImportResult {
  /** åˆ›å»ºæˆåŠŸçš„åˆåŒç¼–å· */
  createList?: string[];
  /** æ›´æ–°æˆåŠŸçš„åˆåŒç¼–å· */
  updateList?: string[];
  /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºåˆåŒç¼–号或行号,value ä¸ºå¤±è´¥åŽŸå›  */
  failureList?: Record<string, string>;
}
/** ä¸‹è½½åˆåŒå¯¼å…¥æ¨¡æ¿ */
export function importContractTemplate() {
  return requestClient.download('/crm/contract/get-import-template');
}
/** å¯¼å…¥åˆåŒ */
export function importContract(file: File) {
  return requestClient.upload<ImportResult>('/crm/contract/import', { file });
}
src/api/crm/invoice/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmInvoiceApi {
  /** åˆåŒä¿¡æ¯ */
  export interface Contract {
    id?: number;
    name?: string;
    no: string;
  }
  /** é™„件信息 */
  export interface Attachment {
    id: number;
    url?: string;
    name?: string;
  }
  /** å¼€ç¥¨ä¿¡æ¯ */
  export interface Invoice {
    id?: number;
    no?: string;
    contractId?: number;
    contract?: Contract;
    customerId?: number;
    customerName?: string;
    invoiceType?: number;
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
    invoiceTime?: string;
    auditStatus?: number;
    remark?: string;
    ownerUserId?: number;
    ownerUserName?: string;
    ownerUserDeptName?: string;
    processInstanceId?: string;
    attachmentList?: Attachment[];
    blobIds?: number[];
    hasAttachment?: boolean;
    remainingInvoicePrice?: number;
    creator?: string;
    creatorName?: string;
    createTime?: string;
    updateTime?: string;
  }
  /** å¼€ç¥¨ç®€è¦ä¿¡æ¯ï¼ˆç”¨äºŽå›žæ¬¾å…³è”选择) */
  export interface InvoiceSimple {
    id: number;
    no: string;
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
    customerId?: number;
    contractId?: number;
    auditStatus?: number;
    hasAttachment?: boolean;
    remainingInvoicePrice?: number;
  }
}
/** æŸ¥è¯¢å¼€ç¥¨åˆ†é¡µ */
export function getInvoicePage(params: PageParam) {
  return requestClient.get<PageResult<CrmInvoiceApi.Invoice>>(
    '/crm/invoice/page',
    { params },
  );
}
/** æŸ¥è¯¢å¼€ç¥¨åˆ†é¡µï¼ŒåŸºäºŽæŒ‡å®šå®¢æˆ· */
export function getInvoicePageByCustomer(params: PageParam) {
  return requestClient.get<PageResult<CrmInvoiceApi.Invoice>>(
    '/crm/invoice/page-by-customer',
    { params },
  );
}
/** æŸ¥è¯¢å¼€ç¥¨è¯¦æƒ… */
export function getInvoice(id: number) {
  return requestClient.get<CrmInvoiceApi.Invoice>(
    `/crm/invoice/get?id=${id}`,
  );
}
/** æ–°å¢žå¼€ç¥¨ */
export function createInvoice(data: CrmInvoiceApi.Invoice) {
  return requestClient.post('/crm/invoice/create', data);
}
/** ä¿®æ”¹å¼€ç¥¨ */
export function updateInvoice(data: CrmInvoiceApi.Invoice) {
  return requestClient.put('/crm/invoice/update', data);
}
/** åˆ é™¤å¼€ç¥¨ */
export function deleteInvoice(id: number) {
  return requestClient.delete(`/crm/invoice/delete?id=${id}`);
}
/** å¯¼å‡ºå¼€ç¥¨ */
export function exportInvoice(params: Record<string, unknown>) {
  return requestClient.download('/crm/invoice/export-excel', { params });
}
/** æäº¤å®¡æ ¸ */
export function submitInvoice(id: number) {
  return requestClient.put(`/crm/invoice/submit?id=${id}`);
}
/** èŽ·å¾—å¾…å®¡æ ¸å¼€ç¥¨æ•°é‡ */
export function getAuditInvoiceCount() {
  return requestClient.get<number>('/crm/invoice/audit-count');
}
/** æŸ¥è¯¢å¼€ç¥¨ä¸‹æ‹‰åˆ—表(按合同查可选开票) */
export function getInvoiceSimpleList(contractId: number) {
  return requestClient.get<CrmInvoiceApi.InvoiceSimple[]>(
    `/crm/invoice/simple-list?contractId=${contractId}`,
  );
}
src/api/crm/permission/index.ts
@@ -43,6 +43,7 @@
  CRM_PRODUCT = 6, // äº§å“
  CRM_RECEIVABLE = 7, // å›žæ¬¾
  CRM_RECEIVABLE_PLAN = 8, // å›žæ¬¾è®¡åˆ’
  CRM_INVOICE = 9, // å¼€ç¥¨
}
/**
src/api/crm/receivable/index.ts
@@ -14,6 +14,8 @@
    contractId?: number;
    contractNo?: string;
    contract?: Contract;
    invoiceId?: number;
    invoice?: InvoiceInfo;
    auditStatus: number;
    processInstanceId: number;
    returnTime: Date;
@@ -34,6 +36,15 @@
    name?: string;
    no: string;
    totalPrice: number;
  }
  /** å¼€ç¥¨ä¿¡æ¯ */
  export interface InvoiceInfo {
    id?: number;
    no?: string;
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
  }
  export interface ReceivablePageParam extends PageParam {
@@ -87,9 +98,11 @@
  return requestClient.download('/crm/receivable/export-excel', { params });
}
/** æäº¤å®¡æ ¸ */
export function submitReceivable(id: number) {
  return requestClient.put(`/crm/receivable/submit?id=${id}`);
/** æ›´æ–°å›žæ¬¾çš„状态(审核/反审核) */
export function updateReceivableStatus(id: number, status: number) {
  return requestClient.put('/crm/receivable/update-status', null, {
    params: { id, status },
  });
}
/** èŽ·å¾—å¾…å®¡æ ¸å›žæ¬¾æ•°é‡ */
src/api/crm/saleQuotation/ai/index.ts
@@ -9,7 +9,7 @@
    quotationPrice?: number;
  }
  /** AI OCR è¯†åˆ«æŠ¥ä»·æ–‡ä»¶å“åº” */
  /** è¯†åˆ«æŠ¥ä»·æ–‡ä»¶å“åº” */
  export interface OcrResultVO {
    name?: string;
    customerName?: string;
@@ -23,7 +23,7 @@
  }
}
/** AI OCR è¯†åˆ«æŠ¥ä»·æ–‡ä»¶ */
/** è¯†åˆ«æŠ¥ä»·æ–‡ä»¶ */
export function ocrSaleQuotation(blobId: number) {
  return requestClient.post<CrmSaleQuotationAiApi.OcrResultVO>(
    '/crm/sale-quotation/ai/ocr',
src/api/crm/salesTarget/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmSalesTargetApi {
  /** é”€å”®ç›®æ ‡ä¿¡æ¯ */
  export interface SalesTarget {
    id?: number;
    name?: string;
    periodType?: number;
    periodValue?: string;
    startTime?: string;
    endTime?: string;
    targetDimension?: number;
    targetUserId?: number;
    targetUserName?: string;
    targetDeptId?: number;
    targetDeptName?: string;
    targetItemId?: number;
    targetItemName?: string;
    contractTarget?: number;
    receivableTarget?: number;
    businessTarget?: number;
    customerTarget?: number;
    contractCountTarget?: number;
    actualContractPrice?: number;
    actualReceivablePrice?: number;
    actualBusinessPrice?: number;
    actualCustomerCount?: number;
    actualContractCount?: number;
    contractRate?: number;
    receivableRate?: number;
    businessRate?: number;
    customerRate?: number;
    contractCountRate?: number;
    status?: number;
    remark?: string;
    creator?: string;
    creatorName?: string;
    createTime?: string;
    updateTime?: string;
  }
  /** é”€å”®ç›®æ ‡åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    name?: string;
    periodType?: number;
    periodValue?: string;
    targetDimension?: number;
    status?: number;
  }
  /** é”€å”®ç›®æ ‡è¾¾æˆåˆ†æžæŸ¥è¯¢å‚æ•° */
  export interface AnalysisParams {
    periodType?: number;
    periodValue?: string;
    targetDimension?: number;
    status?: number;
  }
}
/** æŸ¥è¯¢é”€å”®ç›®æ ‡åˆ†é¡µ */
export function getSalesTargetPage(params: CrmSalesTargetApi.PageParams) {
  return requestClient.get<PageResult<CrmSalesTargetApi.SalesTarget>>(
    '/crm/sales-target/page',
    { params },
  );
}
/** æŸ¥è¯¢é”€å”®ç›®æ ‡è¯¦æƒ… */
export function getSalesTarget(id: number) {
  return requestClient.get<CrmSalesTargetApi.SalesTarget>(
    `/crm/sales-target/get?id=${id}`,
  );
}
/** æ–°å¢žé”€å”®ç›®æ ‡ */
export function createSalesTarget(data: CrmSalesTargetApi.SalesTarget) {
  return requestClient.post('/crm/sales-target/create', data);
}
/** ä¿®æ”¹é”€å”®ç›®æ ‡ */
export function updateSalesTarget(data: CrmSalesTargetApi.SalesTarget) {
  return requestClient.put('/crm/sales-target/update', data);
}
/** å˜æ›´é”€å”®ç›®æ ‡çŠ¶æ€ï¼ˆä½œåºŸ/恢复等) */
export function changeSalesTargetStatus(data: {
  id: number;
  status: number;
}) {
  return requestClient.put('/crm/sales-target/change-status', data);
}
/** åˆ é™¤é”€å”®ç›®æ ‡ */
export function deleteSalesTarget(id: number) {
  return requestClient.delete(`/crm/sales-target/delete?id=${id}`);
}
/** å¯¼å‡ºé”€å”®ç›®æ ‡ */
export function exportSalesTarget(params: Record<string, unknown>) {
  return requestClient.download('/crm/sales-target/export-excel', { params });
}
/** æŸ¥è¯¢é”€å”®ç›®æ ‡è¾¾æˆåˆ†æž */
export function getSalesTargetAnalysis(
  params: CrmSalesTargetApi.AnalysisParams,
) {
  return requestClient.get<CrmSalesTargetApi.SalesTarget[]>(
    '/crm/sales-target/get-achievement-analysis',
    { params },
  );
}
src/api/erp/finance/payment/index.ts
@@ -1,5 +1,7 @@
import type { PageParam, PageResult } from '@vben/request';
import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
import { requestClient } from '#/api/request';
export namespace ErpFinancePaymentApi {
@@ -9,11 +11,14 @@
    no: string; // ä»˜æ¬¾å•号
    supplierId?: number; // ä¾›åº”商编号
    supplierName?: string; // ä¾›åº”商名称
    invoiceId?: number; // å…³è”来票编号
    invoice?: ErpPurchaseInvoiceApi.PurchaseInvoice; // å…³è”来票信息
    paymentTime?: Date; // ä»˜æ¬¾æ—¶é—´
    totalPrice: number; // åˆè®¡é‡‘额,单位:元
    discountPrice: number; // ä¼˜æƒ é‡‘额
    paymentPrice: number; // å®žé™…付款金额
    status: number; // çŠ¶æ€
    processInstanceId?: string; // å·¥ä½œæµç¼–号
    remark: string; // å¤‡æ³¨
    attachmentList?: { id: number; name?: string; url?: string }[]; // é™„件列表
    accountId?: number; // ä»˜æ¬¾è´¦æˆ·
@@ -71,13 +76,18 @@
  return requestClient.put('/erp/finance-payment/update', data);
}
/** æ›´æ–°ä»˜æ¬¾å•的状态 */
export function updateFinancePaymentStatus(id: number, status: number) {
  return requestClient.put('/erp/finance-payment/update-status', null, {
    params: { id, status },
/** æäº¤ä»˜æ¬¾å•审批 */
export function submitFinancePayment(id: number, processDefinitionKey: string) {
  return requestClient.post('/erp/finance-payment/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–ä»˜æ¬¾å•å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getFinancePaymentApproveProcessList() {
  return requestClient.get('/erp/finance-payment/approve-process-list');
}
/** åˆ é™¤ä»˜æ¬¾å• */
export function deleteFinancePayment(ids: number[]) {
  return requestClient.delete('/erp/finance-payment/delete', {
src/api/erp/purchase/invoice/ai/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
import { requestClient } from '#/api/request';
export namespace ErpPurchaseInvoiceAiApi {
  /** è¯†åˆ«å‘票响应 */
  export interface OcrResultVO {
    /** å‘票号码 */
    invoiceNo?: string;
    /** å‘票抬头(购买方名称) */
    invoiceTitle?: string;
    /** é”€å”®æ–¹åç§°ï¼ˆæ–‡æœ¬ï¼Œä¾›æ ¸å¯¹ä¾›åº”商) */
    supplierName?: string;
    /** æ¥ç¥¨é‡‘额(价税合计),单位:元 */
    price?: number;
    /** å¼€ç¥¨æ—¥æœŸï¼Œæ ¼å¼ yyyy-MM-dd */
    invoiceTime?: string;
    /** å‘票类型 */
    invoiceType?: string;
    /** ç¨ŽçŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰ */
    taxRate?: number;
    /** å¤‡æ³¨ */
    remark?: string;
    /** åŽŸå§‹è¯†åˆ«æ–‡æœ¬ï¼ˆè¯†åˆ«å¤±è´¥æ—¶ä¸ºé”™è¯¯æç¤ºï¼‰ */
    rawText?: string;
  }
}
/** è¯†åˆ«å‘票文件 */
export function ocrPurchaseInvoice(blobId: number) {
  return requestClient.post<ErpPurchaseInvoiceAiApi.OcrResultVO>(
    '/erp/purchase-invoice/ai/ocr',
    { blobId },
  );
}
src/api/erp/purchase/invoice/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ErpPurchaseInvoiceApi {
  /** é‡‡è´­è®¢å•信息(来票关联) */
  export interface PurchaseOrder {
    id?: number;
    no?: string;
    supplierId?: number;
    supplierName?: string;
    orderTime?: Date | string;
    totalPrice?: number;
    status?: number;
  }
  /** æ¥ç¥¨ä¿¡æ¯ */
  export interface PurchaseInvoice {
    id?: number;
    no?: string;
    supplierId?: number;
    supplierName?: string;
    purchaseOrderId?: number;
    order?: PurchaseOrder;
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
    invoiceTime?: string;
    remark?: string;
    attachmentList?: { id: number; name?: string; url?: string }[];
    blobIds?: number[];
    hasAttachment?: boolean;
    remainingInvoicePrice?: number;
    creator?: string;
    creatorName?: string;
    createTime?: string;
    updateTime?: string;
  }
  /** æ¥ç¥¨ç®€è¦ä¿¡æ¯ï¼ˆç”¨äºŽä»˜æ¬¾å…³è”选择) */
  export interface PurchaseInvoiceSimple {
    id: number;
    no: string;
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
    supplierId?: number;
    purchaseOrderId?: number;
    hasAttachment?: boolean;
    remainingInvoicePrice?: number;
  }
}
/** æŸ¥è¯¢æ¥ç¥¨åˆ†é¡µ */
export function getPurchaseInvoicePage(params: PageParam) {
  return requestClient.get<PageResult<ErpPurchaseInvoiceApi.PurchaseInvoice>>(
    '/erp/purchase-invoice/page',
    { params },
  );
}
/** æŸ¥è¯¢æ¥ç¥¨è¯¦æƒ… */
export function getPurchaseInvoice(id: number) {
  return requestClient.get<ErpPurchaseInvoiceApi.PurchaseInvoice>(
    `/erp/purchase-invoice/get?id=${id}`,
  );
}
/** æ–°å¢žæ¥ç¥¨ */
export function createPurchaseInvoice(data: ErpPurchaseInvoiceApi.PurchaseInvoice) {
  return requestClient.post('/erp/purchase-invoice/create', data);
}
/** ä¿®æ”¹æ¥ç¥¨ */
export function updatePurchaseInvoice(data: ErpPurchaseInvoiceApi.PurchaseInvoice) {
  return requestClient.put('/erp/purchase-invoice/update', data);
}
/** åˆ é™¤æ¥ç¥¨ */
export function deletePurchaseInvoice(id: number) {
  return requestClient.delete(`/erp/purchase-invoice/delete?id=${id}`);
}
/** å¯¼å‡ºæ¥ç¥¨ */
export function exportPurchaseInvoice(params: Record<string, unknown>) {
  return requestClient.download('/erp/purchase-invoice/export-excel', { params });
}
/** æŸ¥è¯¢æ¥ç¥¨ä¸‹æ‹‰åˆ—表(按供应商可选来票,已上传发票附件且剩余可付金额大于 0) */
export function getPurchaseInvoiceSimpleList(
  supplierId: number,
  purchaseOrderId?: number,
) {
  const params = `supplierId=${supplierId}${
    purchaseOrderId ? `&purchaseOrderId=${purchaseOrderId}` : ''
  }`;
  return requestClient.get<ErpPurchaseInvoiceApi.PurchaseInvoiceSimple[]>(
    `/erp/purchase-invoice/simple-list?${params}`,
  );
}
src/api/erp/purchase/order/index.ts
@@ -13,6 +13,8 @@
    totalCount?: number; // åˆè®¡æ•°é‡
    totalPrice?: number; // åˆè®¡é‡‘额,单位:元
    totalProductPrice?: number; // äº§å“é‡‘额,单位:元
    totalTaxPrice?: number; // åˆè®¡ç¨Žé¢ï¼Œå•位:元
    remainInvoicePrice?: number; // å‰©ä½™å¯å¼€ç¥¨é‡‘额(订单总金额-已来票金额),单位:元
    discountPercent?: number; // ä¼˜æƒ çŽ‡ï¼Œç™¾åˆ†æ¯”
    discountPrice?: number; // ä¼˜æƒ é‡‘额,单位:元
    depositPrice?: number; // å®šé‡‘金额,单位:元
@@ -103,6 +105,23 @@
  return requestClient.get<any[]>('/erp/purchase-order/approve-process-list');
}
/** åå®¡æ‰¹ï¼ˆååŒåŠžå…¬å·²åŠžä»»åŠ¡ä¸­æ’¤å›žå·²å®¡æ ¸è®¢å•ï¼Œå›žé€€ä¸ºæœªå®¡æ ¸ï¼‰ */
export function reverseApprove(processInstanceId: string) {
  return requestClient.post('/erp/purchase-order/reverse-approve', null, {
    params: { processInstanceId },
  });
}
/** é‡‡è´­è®¢å•导入结果 */
export interface ImportResult {
  /** åˆ›å»ºæˆåŠŸçš„é‡‡è´­å•ç¼–å· */
  createList?: string[];
  /** æ›´æ–°æˆåŠŸçš„é‡‡è´­å•ç¼–å· */
  updateList?: string[];
  /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºé‡‡è´­å•编号或行号,value ä¸ºå¤±è´¥åŽŸå›  */
  failureList?: Record<string, string>;
}
/** èŽ·å–é‡‡è´­å®¡æ ¸æµç¨‹åˆ—è¡¨å“åº” */
export interface ProcessDefinition {
  id: string; // æµç¨‹å®šä¹‰ID
@@ -117,3 +136,16 @@
    params: { id },
  });
}
/** ä¸‹è½½é‡‡è´­è®¢å•导入模板 */
export function importPurchaseOrderTemplate() {
  return requestClient.download('/erp/purchase-order/get-import-template');
}
/** å¯¼å…¥é‡‡è´­è®¢å• */
export function importPurchaseOrder(file: File, directApprove: boolean) {
  return requestClient.upload<ImportResult>(
    '/erp/purchase-order/import',
    { file, directApprove },
  );
}
src/api/erp/purchase/request/index.ts
@@ -46,6 +46,16 @@
  }
}
/** é‡‡è´­ç”³è¯·å¯¼å…¥ç»“æžœ */
export interface ImportResult {
  /** åˆ›å»ºæˆåŠŸçš„é‡‡è´­ç”³è¯·å•å· */
  createList?: string[];
  /** æ›´æ–°æˆåŠŸçš„é‡‡è´­ç”³è¯·å•å· */
  updateList?: string[];
  /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºé‡‡è´­ç”³è¯·å•号或行号,value ä¸ºå¤±è´¥åŽŸå›  */
  failureList?: Record<string, string>;
}
/** åˆ›å»ºé‡‡è´­ç”³è¯· */
export function createPurchaseRequest(data: ErpPurchaseRequestApi.PurchaseRequest) {
  return requestClient.post('/erp/purchase-request/create', data);
@@ -59,6 +69,11 @@
/** æäº¤é‡‡è´­ç”³è¯·å®¡æ‰¹ */
export function submitPurchaseRequest(id: number, processDefinitionKey: string) {
  return requestClient.put('/erp/purchase-request/submit', null, { params: { id, processDefinitionKey } });
}
/** é‡ç½®è¢«é©³å›žçš„采购申请为草稿状态 */
export function resetPurchaseRequestStatus(id: number) {
  return requestClient.put('/erp/purchase-request/reset-status', null, { params: { id } });
}
/** ç”Ÿæˆé‡‡è´­è®¢å• */
@@ -95,3 +110,47 @@
export function getApproveProcessList(id: number) {
  return requestClient.get(`/erp/purchase-request/approve-process-list`, { params: { id } });
}
/** BPM æµç¨‹å®žä¾‹çŠ¶æ€ â†’ ä¸šåŠ¡çŠ¶æ€æ˜ å°„ */
export const BPM_STATUS_TO_BIZ: Record<number, number> = {
  1: 10, // RUNNING â†’ å®¡æ‰¹ä¸­
  2: 20, // APPROVE â†’ å®¡æ ¸é€šè¿‡
  3: 30, // REJECT â†’ å®¡æ ¸ä¸é€šè¿‡
  4: 40, // CANCEL â†’ å·²å–消
};
/**
 * åŒæ­¥é‡‡è´­ç”³è¯·çš„业务状态(根据 BPM æµç¨‹å®žä¾‹å®žé™…状态)
 * ç”¨äºŽä¿®å¤åŽç«¯ç›‘听器未正确回写导致的业务状态不一致问题
 */
export async function syncPurchaseRequestStatus(items: ErpPurchaseRequestApi.PurchaseRequest[]) {
  const processingItems = items.filter(
    (item) => item.status === 10 && item.processInstanceId,
  );
  if (processingItems.length === 0) return;
  const { getApprovalDetail } = await import('#/api/bpm/processInstance');
  for (const item of processingItems) {
    try {
      const detail = await getApprovalDetail({
        processInstanceId: item.processInstanceId,
      });
      const bizStatus = BPM_STATUS_TO_BIZ[detail.processInstance?.status];
      if (bizStatus && bizStatus !== 10) {
        item.status = bizStatus;
      }
    } catch {
      // æŸ¥è¯¢å¤±è´¥æ—¶ä¿æŒåŽŸæœ‰çŠ¶æ€
    }
  }
}
/** ä¸‹è½½é‡‡è´­ç”³è¯·å¯¼å…¥æ¨¡æ¿ */
export function importPurchaseRequestTemplate() {
  return requestClient.download('/erp/purchase-request/get-import-template');
}
/** å¯¼å…¥é‡‡è´­ç”³è¯· */
export function importPurchaseRequest(file: File) {
  return requestClient.upload<ImportResult>('/erp/purchase-request/import', { file });
}
src/api/erp/sale/order/index.ts
@@ -10,11 +10,13 @@
    customerId: number; // å®¢æˆ·ç¼–号
    customerName?: string; // å®¢æˆ·åç§°
    accountId?: number; // æ”¶æ¬¾è´¦æˆ·ç¼–号
    saleUserId?: number; // é”€å”®äººå‘˜ç¼–号
    orderTime: Date; // è®¢å•æ—¶é—´
    totalCount: number; // åˆè®¡æ•°é‡
    totalPrice: number; // åˆè®¡é‡‘额,单位:元
    status: number; // çŠ¶æ€
    remark: string; // å¤‡æ³¨
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ç¼–号(提交协同办公审批后生成)
    outCount: number; // é”€å”®å‡ºåº“数量
    outStatus?: number; // å‡ºåº“状态:0-未出库,1-部分出库,2-全部出库
    attachmentList?: { id: number; name?: string; url?: string }[]; // é™„件列表
@@ -31,6 +33,9 @@
    productionFinishTime?: Date; // ç”Ÿäº§å®Œæˆæ—¶é—´
    hasSalesNotice?: boolean; // æ˜¯å¦å·²ç”Ÿæˆå‘货通知单
    canCreateSalesNotice?: boolean; // æ˜¯å¦å¯ä»¥ç”Ÿæˆå‘货通知单
    creator?: string; // åˆ›å»ºäººç¼–号(字符串)
    creatorName?: string; // åˆ›å»ºäººåç§°
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    items?: SaleOrderItem[]; // é”€å”®è®¢å•产品明细列表
  }
@@ -53,10 +58,21 @@
    totalTaxPrice?: number; // å«ç¨Žæ€»ä»·ï¼Œå•位:元
    remark?: string; // å¤‡æ³¨
    stockCount?: number; // åº“存数量(显示字段)
    productSpecification?: string; // è§„格型号(显示字段)
    needProduction?: number; // æ˜¯å¦éœ€è¦ç”Ÿäº§ï¼š0-不需要,1-需要
    mpsId?: number; // MPS编号
    batchCode?: string; // æ‰¹æ¬¡å·
  }
}
/** é”€å”®è®¢å•导入结果 */
export interface ImportResult {
  /** åˆ›å»ºæˆåŠŸçš„é”€å”®å•ç¼–å· */
  createList?: string[];
  /** æ›´æ–°æˆåŠŸçš„é”€å”®å•ç¼–å· */
  updateList?: string[];
  /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºé”€å”®å•编号或行号,value ä¸ºå¤±è´¥åŽŸå›  */
  failureList?: Record<string, string>;
}
/** æŸ¥è¯¢é”€å”®è®¢å•分页 */
@@ -91,11 +107,18 @@
  return requestClient.put('/erp/sale-order/update', data);
}
/** æ›´æ–°é”€å”®è®¢å•的状态 */
export function updateSaleOrderStatus(id: number, status: number) {
  return requestClient.put('/erp/sale-order/update-status', null, {
    params: { id, status },
/** æäº¤é”€å”®è®¢å•审批(协同办公) */
export function submitSaleOrder(id: number, processDefinitionKey: string) {
  return requestClient.post('/erp/sale-order/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–é”€å”®è®¢å•å®¡æ‰¹æµç¨‹å®šä¹‰åˆ—è¡¨ */
export function getSaleOrderApproveProcessList() {
  return requestClient.get<
    { id: string; key: string; name: string }[]
  >('/erp/sale-order/approve-process-list');
}
/** åˆ é™¤é”€å”®è®¢å• */
@@ -123,3 +146,13 @@
    params: { id },
  });
}
/** ä¸‹è½½é”€å”®è®¢å•导入模板 */
export function importSaleOrderTemplate() {
  return requestClient.download('/erp/sale-order/get-import-template');
}
/** å¯¼å…¥é”€å”®è®¢å• */
export function importSaleOrder(file: File) {
  return requestClient.upload<ImportResult>('/erp/sale-order/import', { file });
}
src/api/hrm/attendance/record/index.ts
@@ -10,6 +10,7 @@
    userName?: string;
    deptId?: number;
    deptName?: string;
    phone?: string;
    date?: string;
    clockInTime?: string;
    clockOutTime?: string;
src/api/hrm/employee/contract/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,156 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmEmployeeContractApi {
  /** å‘˜å·¥åˆåŒ */
  export interface EmployeeContract {
    id?: number;
    /** ç»­ç­¾ä¸Šä¸€ä»½åˆåŒID */
    parentId?: number;
    /** ç»­ç­¾ä¸Šä¸€ä»½åˆåŒç¼–号 */
    parentNo?: string;
    /** æ˜¯å¦å½“前生效合同 */
    isCurrent?: boolean;
    /** åˆåŒç¼–号(后端自动生成) */
    contractNo?: string;
    /** å‘˜å·¥ID */
    employeeId?: number;
    /** å‘˜å·¥å§“名 */
    employeeName?: string;
    /** å‘˜å·¥å·¥å· */
    employeeNo?: string;
    /** éƒ¨é—¨åç§° */
    deptName?: string;
    /** åˆåŒç±»åž‹ï¼š1-劳动合同 2-劳务合同 3-实习协议 4-劳务派遣 5-其他 */
    contractType?: number;
    /** åˆåŒæœŸé™ç±»åž‹ï¼š1-固定期限 2-无固定期限 3-以完成一定工作任务为期限 */
    contractTermType?: number;
    /** ç­¾çº¦ä¸»ä½“ */
    signCompany?: string;
    /** ç­¾è®¢æ—¥æœŸ */
    signDate?: string;
    /** åˆåŒå¼€å§‹æ—¥æœŸ */
    startDate?: string;
    /** åˆåŒç»“束日期 */
    endDate?: string;
    /** è¯•用期开始日期 */
    probationStartDate?: string;
    /** è¯•用期结束日期 */
    probationEndDate?: string;
    /** è¯•用期工资 */
    probationSalary?: number;
    /** è½¬æ­£å·¥èµ„ */
    regularSalary?: number;
    /** è§£é™¤ç»ˆæ­¢çŠ¶æ€ï¼š0-正常 1-已解除 2-已终止 */
    terminateStatus?: number;
    /** è§£é™¤/终止日期 */
    terminateDate?: string;
    /** è§£é™¤/终止原因 */
    terminateReason?: string;
    /** åˆåŒçŠ¶æ€ï¼ˆåŠ¨æ€è®¡ç®—ï¼‰ï¼š1-待生效 2-生效中 3-即将到期 4-已到期 5-已解除 6-已终止 */
    status?: number;
    /** é™„ä»¶ */
    attachmentList?: AttachmentItem[];
    /** é™„ä»¶ blobId åˆ—表 */
    blobIds?: number[];
    remark?: string;
    createTime?: string;
  }
  /** é™„件项 */
  export interface AttachmentItem {
    id: number;
    name?: string;
    url?: string;
    uid?: string;
  }
  /** å‘˜å·¥åˆåŒæŸ¥è¯¢å‚æ•° */
  export interface EmployeeContractPageParam extends PageParam {
    contractNo?: string;
    employeeId?: number;
    contractType?: number;
    contractTermType?: number;
    terminateStatus?: number;
    status?: number;
    /** åˆ°æœŸç±»åž‹ï¼š1-即将到期 2-已到期 */
    expiryType?: number;
  }
  /** è§£é™¤/终止合同请求 */
  export interface TerminateReq {
    id: number;
    terminateStatus: number;
    terminateDate: string;
    terminateReason: string;
  }
  /** åˆåŒå¯¼å…¥ç»“æžœ */
  export interface ImportResult {
    /** æ–°å¢žæˆåŠŸçš„å‘˜å·¥åæ•°ç»„ */
    createNames?: string[];
    /** è¦†ç›–更新成功的员工名数组 */
    updateNames?: string[];
    /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºå‘˜å·¥åï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  */
    failureNames?: Record<string, string>;
  }
}
/** èŽ·å–å‘˜å·¥åˆåŒåˆ†é¡µåˆ—è¡¨ */
export function getEmployeeContractPage(
  params: HrmEmployeeContractApi.EmployeeContractPageParam,
) {
  return requestClient.get<PageResult<HrmEmployeeContractApi.EmployeeContract>>(
    '/hrm/employee-contract/page',
    { params },
  );
}
/** èŽ·å–å‘˜å·¥åˆåŒè¯¦æƒ… */
export function getEmployeeContract(id: number) {
  return requestClient.get<HrmEmployeeContractApi.EmployeeContract>(
    `/hrm/employee-contract/get?id=${id}`,
  );
}
/** åˆ›å»ºå‘˜å·¥åˆåŒ */
export function createEmployeeContract(data: HrmEmployeeContractApi.EmployeeContract) {
  return requestClient.post('/hrm/employee-contract/create', data);
}
/** æ›´æ–°å‘˜å·¥åˆåŒ */
export function updateEmployeeContract(data: HrmEmployeeContractApi.EmployeeContract) {
  return requestClient.put('/hrm/employee-contract/update', data);
}
/** è§£é™¤/终止员工合同 */
export function terminateEmployeeContract(data: HrmEmployeeContractApi.TerminateReq) {
  return requestClient.put('/hrm/employee-contract/terminate', data);
}
/** åˆ é™¤å‘˜å·¥åˆåŒ */
export function deleteEmployeeContract(id: number) {
  return requestClient.delete(`/hrm/employee-contract/delete?id=${id}`);
}
/** èŽ·å–å³å°†åˆ°æœŸåˆåŒæ•°é‡ */
export function getExpiringContractCount() {
  return requestClient.get<number>('/hrm/employee-contract/expiring-count');
}
/** ä¸‹è½½å‘˜å·¥åˆåŒå¯¼å…¥æ¨¡æ¿ */
export function importEmployeeContractTemplate() {
  return requestClient.download('/hrm/employee-contract/import-template');
}
/** å¯¼å…¥å‘˜å·¥åˆåŒ */
export function importEmployeeContract(
  file: File,
  updateSupport: boolean,
) {
  return requestClient.upload<HrmEmployeeContractApi.ImportResult>(
    '/hrm/employee-contract/import',
    { file, updateSupport },
  );
}
src/api/hrm/employee/index.ts
@@ -9,8 +9,8 @@
    userId?: number;
    deptId?: number;
    deptName?: string;
    postId?: number;
    postName?: string;
    postIds?: number[];
    postNames?: string;
    employeeNo?: string;
    name?: string;
    gender?: number;
@@ -18,12 +18,18 @@
    phone?: string;
    email?: string;
    idCard?: string;
    nation?: string;
    marriageStatus?: string;
    age?: number;
    education?: string;
    politicalStatus?: string;
    hireDate?: string;
    regularDate?: string;
    leaveDate?: string;
    employeeStatus?: number;
    annualLeaveBalance?: number;
    sickLeaveBalance?: number;
    salaryStructureId?: number;
    baseSalary?: number;
    roleIds?: number[];
    roleNames?: string[];
@@ -82,6 +88,16 @@
    deptName?: string;
    postName?: string;
  }
  /** å‘˜å·¥å¯¼å…¥ç»“æžœ */
  export interface ImportResult {
    /** åˆ›å»ºæˆåŠŸçš„å‘˜å·¥åæ•°ç»„ */
    createNames?: string[];
    /** æ›´æ–°æˆåŠŸçš„å‘˜å·¥åæ•°ç»„ */
    updateNames?: string[];
    /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºå‘˜å·¥åï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  */
    failureNames?: Record<string, string>;
  }
}
/** æŸ¥è¯¢å‘˜å·¥åˆ†é¡µåˆ—表 */
@@ -126,6 +142,19 @@
  return requestClient.download('/hrm/employee/export-excel', { params });
}
/** ä¸‹è½½å‘˜å·¥å¯¼å…¥æ¨¡æ¿ */
export function importEmployeeTemplate() {
  return requestClient.download('/hrm/employee/import-template');
}
/** å¯¼å…¥å‘˜å·¥ */
export function importEmployee(file: File, updateSupport: boolean) {
  return requestClient.upload<HrmEmployeeApi.ImportResult>(
    '/hrm/employee/import',
    { file, updateSupport },
  );
}
/** åˆ é™¤æ•™è‚²ç»åކ */
export function deleteEducation(id: number) {
  return requestClient.delete(`/hrm/employee/education/delete?id=${id}`);
@@ -139,4 +168,4 @@
/** åˆ é™¤ç´§æ€¥è”系人 */
export function deleteEmergencyContact(id: number) {
  return requestClient.delete(`/hrm/employee/emergency-contact/delete?id=${id}`);
}
}
src/api/hrm/leave-type-config/index.ts
@@ -22,7 +22,17 @@
  );
}
/** æ–°å¢žè¯·å‡ç±»åž‹é…ç½® */
export function createLeaveTypeConfig(data: HrmLeaveTypeConfigApi.LeaveTypeConfig) {
  return requestClient.post<number>('/hrm/leave-type-config/create', data);
}
/** æ›´æ–°è¯·å‡ç±»åž‹é…ç½® */
export function updateLeaveTypeConfig(data: HrmLeaveTypeConfigApi.LeaveTypeConfig) {
  return requestClient.put('/hrm/leave-type-config/update', data);
}
/** åˆ é™¤è¯·å‡ç±»åž‹é…ç½® */
export function deleteLeaveTypeConfig(id: number) {
  return requestClient.delete(`/hrm/leave-type-config/delete?id=${id}`);
}
src/api/hrm/leave/index.ts
@@ -7,8 +7,8 @@
  /** è¯·å‡ç”³è¯· */
  export interface Leave {
    id?: number;
    employeeId?: number;
    employeeName?: string;
    userId?: number;
    userName?: string;
    deptName?: string;
    leaveType?: number;
    startTime?: string;
@@ -21,6 +21,12 @@
    processInstanceId?: string;
    remark?: string;
    createTime?: string;
  }
  /** å®¡æ‰¹æµç¨‹ */
  export interface ApproveProcess {
    key: string;
    name: string;
  }
}
@@ -53,8 +59,10 @@
}
/** æäº¤è¯·å‡ç”³è¯·ï¼ˆå‘起审批) */
export function submitLeave(id: number) {
  return requestClient.post(`/hrm/leave/submit?id=${id}`);
export function submitLeave(id: number, processDefinitionKey: string) {
  return requestClient.post('/hrm/leave/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** æ’¤é”€è¯·å‡ç”³è¯· */
@@ -64,5 +72,7 @@
/** èŽ·å–å¯ç”¨æµç¨‹å®šä¹‰åˆ—è¡¨ */
export function getLeaveApproveProcessDefinitionList() {
  return requestClient.get<any[]>('/hrm/leave/approve-process-definition-list');
  return requestClient.get<HrmLeaveApi.ApproveProcess[]>(
    '/hrm/leave/approve-process-definition-list',
  );
}
src/api/hrm/overtime/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmOvertimeApi {
  /** åŠ ç­ç”³è¯· */
  export interface Overtime {
    id?: number;
    employeeId?: number;
    employeeName?: string;
    employeeNo?: string;
    deptName?: string;
    postName?: string;
    overtimeDate?: string;
    startTime?: string;
    endTime?: string;
    duration?: number;
    overtimeType?: number;
    crossDay?: boolean;
    mealDeduction?: boolean;
    mealDeductionHours?: number;
    reason?: string;
    relatedType?: number;
    relatedCode?: string;
    workContent?: string;
    status?: number;
    processInstanceId?: string;
    createTime?: string;
  }
  export interface ApproveProcess {
    key: string;
    name: string;
  }
}
/** æŸ¥è¯¢åŠ ç­ç”³è¯·åˆ†é¡µåˆ—è¡¨ */
export function getOvertimePage(params: PageParam) {
  return requestClient.get<PageResult<HrmOvertimeApi.Overtime>>(
    '/hrm/overtime/page',
    { params },
  );
}
/** æŸ¥è¯¢åŠ ç­ç”³è¯·è¯¦æƒ… */
export function getOvertime(id: number) {
  return requestClient.get<HrmOvertimeApi.Overtime>(
    `/hrm/overtime/get?id=${id}`,
  );
}
/** èŽ·å–å½“å‰ç”³è¯·äººå…³è”çš„å‘˜å·¥æ¡£æ¡ˆ */
export function getCurrentOvertimeApplicant() {
  return requestClient.get<HrmOvertimeApi.Overtime>('/hrm/overtime/current-applicant');
}
/** æ–°å¢žåŠ ç­ç”³è¯·ï¼ˆè‰ç¨¿ï¼‰ */
export function createOvertime(data: HrmOvertimeApi.Overtime) {
  return requestClient.post('/hrm/overtime/create', data);
}
/** æ›´æ–°åŠ ç­ç”³è¯· */
export function updateOvertime(data: HrmOvertimeApi.Overtime) {
  return requestClient.put('/hrm/overtime/update', data);
}
/** åˆ é™¤åŠ ç­ç”³è¯· */
export function deleteOvertime(id: number) {
  return requestClient.delete(`/hrm/overtime/delete?id=${id}`);
}
/** æäº¤åŠ ç­ç”³è¯·ï¼ˆå‘èµ·å®¡æ‰¹ï¼‰ */
export function submitOvertime(id: number, processDefinitionKey: string) {
  return requestClient.post('/hrm/overtime/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** æ’¤é”€åŠ ç­ç”³è¯· */
export function cancelOvertime(id: number) {
  return requestClient.post(`/hrm/overtime/cancel?id=${id}`);
}
/** èŽ·å–ååŒåŠžå…¬ä¸­çš„å¯ç”¨åŠ ç­å®¡æ‰¹æµç¨‹ */
export function getOvertimeApproveProcessDefinitionList() {
  return requestClient.get<HrmOvertimeApi.ApproveProcess[]>(
    '/hrm/overtime/approve-process-definition-list',
  );
}
src/api/hrm/salary/calculation/index.ts
@@ -26,6 +26,8 @@
    actualSalary?: number;
    workDays?: number;
    overtimeHours?: number;
    /** æŽ’班总工时(小时) */
    totalWorkHours?: number;
    status?: number;
    statusName?: string;
    remark?: string;
src/api/hrm/salary/structure/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
import { requestClient } from '#/api/request';
export namespace HrmSalaryStructureApi {
  export interface SalaryStructure {
    id?: number;
    name?: string;
    overtimePayRatio?: number;
    restDayOvertimePayRatio?: number;
    legalHolidayOvertimePayRatio?: number;
    status?: number;
    remark?: string;
    createTime?: string;
  }
  export interface SalaryStructureQueryParam {
    name?: string;
    status?: number;
  }
}
export function getSalaryStructureList(
  params?: HrmSalaryStructureApi.SalaryStructureQueryParam,
) {
  return requestClient.get<HrmSalaryStructureApi.SalaryStructure[]>(
    '/hrm/salary/structure/list',
    { params },
  );
}
export function getSalaryStructure(id: number) {
  return requestClient.get<HrmSalaryStructureApi.SalaryStructure>(
    `/hrm/salary/structure/get?id=${id}`,
  );
}
export function createSalaryStructure(
  data: HrmSalaryStructureApi.SalaryStructure,
) {
  return requestClient.post('/hrm/salary/structure/create', data);
}
export function updateSalaryStructure(
  data: HrmSalaryStructureApi.SalaryStructure,
) {
  return requestClient.put('/hrm/salary/structure/update', data);
}
export function deleteSalaryStructure(id: number) {
  return requestClient.delete(`/hrm/salary/structure/delete?id=${id}`);
}
src/api/hrm/schedule/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
import { requestClient } from '#/api/request';
export namespace HrmEmployeeScheduleApi {
  /** æŽ’班记录 */
  export interface EmployeeSchedule {
    id?: number;
    employeeId?: number;
    employeeName?: string;
    employeeNo?: string;
    /** æŽ’班日期 yyyy-MM-dd */
    date?: string;
    shiftId?: number;
    shiftName?: string;
    shiftColor?: string;
    remark?: string;
  }
  /** ä¿å­˜é¡¹ï¼ˆæ•´æœˆå…¨é‡æäº¤ï¼ŒshiftId ä¸ºç©ºè¡¨ç¤ºä¼‘息/清除) */
  export interface ScheduleItem {
    employeeId: number;
    /** æŽ’班日期 yyyy-MM-dd */
    date: string;
    shiftId?: number | null;
    remark?: string;
  }
}
/** æŸ¥è¯¢æŸæœˆæŽ’班列表 */
export function getEmployeeScheduleList(params: {
  yearMonth: string;
  deptId?: number;
  employeeId?: number;
}) {
  return requestClient.get<HrmEmployeeScheduleApi.EmployeeSchedule[]>(
    '/hrm/employee-schedule/list',
    { params },
  );
}
/** æ‰¹é‡ä¿å­˜æŽ’班(整月覆盖式:有班次 upsert,无班次删除) */
export function saveEmployeeSchedule(data: HrmEmployeeScheduleApi.ScheduleItem[]) {
  return requestClient.post('/hrm/employee-schedule/save', data);
}
src/api/hrm/shift/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmShiftApi {
  /** ç­æ¬¡å®šä¹‰ */
  export interface Shift {
    id?: number;
    name?: string;
    /** ä¸Šç­æ—¶é—´ HH:mm:ss */
    startTime?: string;
    /** ä¸‹ç­æ—¶é—´ HH:mm:ss */
    endTime?: string;
    /** æ ‡å‡†å·¥æ—¶ï¼ˆå°æ—¶ï¼‰ */
    workHours?: number;
    /** æ˜¯å¦è·¨å¤© */
    crossDay?: boolean;
    /** æ ‡ç­¾é¢œè‰²ï¼ˆåå…­è¿›åˆ¶ï¼‰ */
    color?: string;
    /** çŠ¶æ€ï¼š0-启用,1-禁用 */
    status?: number;
    remark?: string;
    createTime?: string;
  }
}
/** æŸ¥è¯¢ç­æ¬¡åˆ†é¡µåˆ—表 */
export function getShiftPage(params: PageParam) {
  return requestClient.get<PageResult<HrmShiftApi.Shift>>('/hrm/shift/page', {
    params,
  });
}
/** æŸ¥è¯¢ç­æ¬¡åˆ—表(启用状态,供排班页选择) */
export function getShiftList() {
  return requestClient.get<HrmShiftApi.Shift[]>('/hrm/shift/list');
}
/** æŸ¥è¯¢ç­æ¬¡è¯¦æƒ… */
export function getShift(id: number) {
  return requestClient.get<HrmShiftApi.Shift>(`/hrm/shift/get?id=${id}`);
}
/** æ–°å¢žç­æ¬¡ */
export function createShift(data: HrmShiftApi.Shift) {
  return requestClient.post('/hrm/shift/create', data);
}
/** æ›´æ–°ç­æ¬¡ */
export function updateShift(data: HrmShiftApi.Shift) {
  return requestClient.put('/hrm/shift/update', data);
}
/** åˆ é™¤ç­æ¬¡ */
export function deleteShift(id: number) {
  return requestClient.delete(`/hrm/shift/delete?id=${id}`);
}
/** å¯¼å‡ºç­æ¬¡ Excel */
export function exportShift(params: any) {
  return requestClient.download('/hrm/shift/export-excel', { params });
}
src/api/mdm/item/index.ts
@@ -107,7 +107,25 @@
  return requestClient.put('/mdm/item/update-status', { id, status });
}
/** æ›´æ–°ç‰©æ–™åŒæ­¥çŠ¶æ€ */
export function updateItemSync(id: number, sync: boolean) {
  return requestClient.put('/mdm/item/update-sync', { id, sync });
}
/** å¯¼å‡ºç‰©æ–™ */
export function exportItem(params: Partial<MdmItemApi.Item>) {
  return requestClient.download('/mdm/item/export-excel', { params });
}
/** ä¸‹è½½ç‰©æ–™å¯¼å…¥æ¨¡æ¿ */
export function importItemTemplate() {
  return requestClient.download('/mdm/item/get-import-template');
}
/** å¯¼å…¥ç‰©æ–™ */
export function importItem(file: File, updateSupport: boolean) {
  return requestClient.upload('/mdm/item/import', {
    file,
    updateSupport,
  });
}
src/api/mdm/unit/index.ts
@@ -47,3 +47,21 @@
export function deleteUnit(id: number) {
  return requestClient.delete(`/mdm/unit/delete?id=${id}`);
}
/** å¯¼å‡ºè®¡é‡å•位 */
export function exportUnit(params: Record<string, any>) {
  return requestClient.download('/mdm/unit/export-excel', { params });
}
/** ä¸‹è½½è®¡é‡å•位导入模板 */
export function importUnitTemplate() {
  return requestClient.download('/mdm/unit/get-import-template');
}
/** å¯¼å…¥è®¡é‡å•位 */
export function importUnit(file: File, updateSupport: boolean) {
  return requestClient.upload('/mdm/unit/import', {
    file,
    updateSupport,
  });
}
src/api/mes/dv/metering/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesDvMeteringApi {
  /** MES è®¡é‡å™¨å…· */
  export interface Metering {
    id?: number; // ä¸»é”®ID
    code?: string; // è®¡é‡å™¨å…·ç¼–码
    name?: string; // è®¡é‡å™¨å…·åç§°
    specification?: string; // è§„格型号
    brand?: string; // å“ç‰Œ/生产厂家
    category?: number; // å™¨å…·ç±»åˆ«ï¼ˆå­—典)
    precision?: string; // ç²¾åº¦
    range?: string; // é‡ç¨‹
    deptId?: number; // ä½¿ç”¨éƒ¨é—¨ç¼–号
    deptName?: string; // ä½¿ç”¨éƒ¨é—¨åç§°
    chargeUserId?: number; // è´£ä»»äººç¼–号
    chargeUserNickname?: string; // è´£ä»»äººæ˜µç§°
    checkCycleCount?: number; // æ ¡å‡†å‘¨æœŸï¼ˆæœˆï¼‰
    useDate?: string; // æŠ•入使用日期
    lastCheckDate?: string; // ä¸Šæ¬¡æ£€æµ‹æ—¥æœŸ
    nextCheckDate?: string; // ä¸‹æ¬¡æ£€æµ‹æ—¥æœŸï¼ˆåˆ°æœŸæ—¥ï¼‰
    status?: number; // çŠ¶æ€ï¼ˆå­—å…¸ï¼‰
    expireStatus?: number; // åˆ°æœŸçŠ¶æ€ï¼š1 å·²è¿‡æœŸ / 2 å³å°†åˆ°æœŸ / 3 æ­£å¸¸
    expireDays?: number; // è·åˆ°æœŸå¤©æ•°
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
}
/** è®¡é‡å™¨å…·å¯¼å…¥ç»“æžœ */
export interface ImportResult {
  /** åˆ›å»ºæˆåŠŸçš„è®¡é‡å™¨å…·ç¼–ç  */
  createList?: string[];
  /** æ›´æ–°æˆåŠŸçš„è®¡é‡å™¨å…·ç¼–ç  */
  updateList?: string[];
  /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºè®¡é‡å™¨å…·ç¼–码或行号,value ä¸ºå¤±è´¥åŽŸå›  */
  failureList?: Record<string, string>;
}
/** æŸ¥è¯¢è®¡é‡å™¨å…·åˆ†é¡µ */
export function getMeteringPage(params: PageParam) {
  return requestClient.get<PageResult<MesDvMeteringApi.Metering>>(
    '/mes/dv/metering/page',
    { params },
  );
}
/** æŸ¥è¯¢è®¡é‡å™¨å…·è¯¦æƒ… */
export function getMetering(id: number) {
  return requestClient.get<MesDvMeteringApi.Metering>(
    `/mes/dv/metering/get?id=${id}`,
  );
}
/** æ–°å¢žè®¡é‡å™¨å…· */
export function createMetering(data: MesDvMeteringApi.Metering) {
  return requestClient.post<number>('/mes/dv/metering/create', data);
}
/** ä¿®æ”¹è®¡é‡å™¨å…· */
export function updateMetering(data: MesDvMeteringApi.Metering) {
  return requestClient.put('/mes/dv/metering/update', data);
}
/** åˆ é™¤è®¡é‡å™¨å…· */
export function deleteMetering(id: number) {
  return requestClient.delete(`/mes/dv/metering/delete?id=${id}`);
}
/** å¯¼å‡ºè®¡é‡å™¨å…· */
export function exportMetering(params: any) {
  return requestClient.download('/mes/dv/metering/export-excel', { params });
}
/** æŸ¥è¯¢è®¡é‡å™¨å…·ç²¾ç®€åˆ—表(下拉选项) */
export function getMeteringSimpleList() {
  return requestClient.get<MesDvMeteringApi.Metering[]>(
    '/mes/dv/metering/simple-list',
  );
}
/** ä¸‹è½½è®¡é‡å™¨å…·å¯¼å…¥æ¨¡æ¿ */
export function importMeteringTemplate() {
  return requestClient.download('/mes/dv/metering/get-import-template');
}
/** å¯¼å…¥è®¡é‡å™¨å…· */
export function importMetering(file: File) {
  return requestClient.upload<ImportResult>('/mes/dv/metering/import', { file });
}
src/api/mes/dv/meteringcheck/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesDvMeteringCheckApi {
  /** MES è®¡é‡æ£€æµ‹è®°å½• */
  export interface MeteringCheck {
    id?: number; // ä¸»é”®ID
    meteringId?: number; // è®¡é‡å™¨å…·ID
    meteringCode?: string; // è®¡é‡å™¨å…·ç¼–码
    meteringName?: string; // è®¡é‡å™¨å…·åç§°
    checkCode?: string; // æ£€æµ‹ç¼–号
    checkDate?: string; // æ£€æµ‹æ—¥æœŸ
    checkOrg?: string; // æ£€æµ‹æœºæž„
    checkResult?: number; // æ£€æµ‹ç»“论(字典)
    certNo?: string; // è¯ä¹¦ç¼–号
    checkUserId?: number; // æ£€æµ‹äººç¼–号
    checkUserNickname?: string; // æ£€æµ‹äººæ˜µç§°
    nextCheckDate?: string; // ä¸‹æ¬¡æ£€æµ‹æ—¥æœŸ
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
}
/** æŸ¥è¯¢è®¡é‡æ£€æµ‹è®°å½•分页 */
export function getMeteringCheckPage(params: PageParam) {
  return requestClient.get<PageResult<MesDvMeteringCheckApi.MeteringCheck>>(
    '/mes/dv/metering-check/page',
    { params },
  );
}
/** æŸ¥è¯¢è®¡é‡æ£€æµ‹è®°å½•详情 */
export function getMeteringCheck(id: number) {
  return requestClient.get<MesDvMeteringCheckApi.MeteringCheck>(
    `/mes/dv/metering-check/get?id=${id}`,
  );
}
/** æ–°å¢žè®¡é‡æ£€æµ‹è®°å½• */
export function createMeteringCheck(data: MesDvMeteringCheckApi.MeteringCheck) {
  return requestClient.post<number>('/mes/dv/metering-check/create', data);
}
/** ä¿®æ”¹è®¡é‡æ£€æµ‹è®°å½• */
export function updateMeteringCheck(data: MesDvMeteringCheckApi.MeteringCheck) {
  return requestClient.put('/mes/dv/metering-check/update', data);
}
/** åˆ é™¤è®¡é‡æ£€æµ‹è®°å½• */
export function deleteMeteringCheck(id: number) {
  return requestClient.delete(`/mes/dv/metering-check/delete?id=${id}`);
}
/** å¯¼å‡ºè®¡é‡æ£€æµ‹è®°å½• */
export function exportMeteringCheck(params: any) {
  return requestClient.download('/mes/dv/metering-check/export-excel', {
    params,
  });
}
src/api/mes/md/item/index.ts
@@ -54,13 +54,6 @@
  return requestClient.put('/mes/md/item/update', data);
}
/** ä¿®æ”¹ç‰©æ–™äº§å“çŠ¶æ€ */
export function updateItemStatus(id: number, status: number) {
  return requestClient.put('/mes/md/item/update-status', undefined, {
    params: { id, status },
  });
}
/** åˆ é™¤ç‰©æ–™äº§å“ */
export function deleteItem(id: number) {
  return requestClient.delete(`/mes/md/item/delete?id=${id}`);
src/api/mes/md/item/type/index.ts
@@ -52,3 +52,21 @@
export function deleteItemType(id: number) {
  return requestClient.delete(`/mes/md/item-type/delete?id=${id}`);
}
/** å¯¼å‡ºç‰©æ–™äº§å“åˆ†ç±» */
export function exportItemType(params: Record<string, any>) {
  return requestClient.download('/mes/md/item-type/export-excel', { params });
}
/** ä¸‹è½½ç‰©æ–™äº§å“åˆ†ç±»å¯¼å…¥æ¨¡æ¿ */
export function importItemTypeTemplate() {
  return requestClient.download('/mes/md/item-type/get-import-template');
}
/** å¯¼å…¥ç‰©æ–™äº§å“åˆ†ç±» */
export function importItemType(file: File, updateSupport: boolean) {
  return requestClient.upload('/mes/md/item-type/import', {
    file,
    updateSupport,
  });
}
src/api/mes/md/workstation/worker/index.ts
@@ -1,4 +1,5 @@
import { requestClient } from '#/api/request';
import type { SystemUserApi } from '#/api/system/user';
export namespace MesMdWorkstationWorkerApi {
  /** MES å·¥ä½œç«™äººåŠ›èµ„æº */
@@ -21,6 +22,17 @@
  );
}
/** èŽ·å¾—å·¥ä½œç«™å¯¹åº”å²—ä½çš„å‘˜å·¥åˆ—è¡¨ */
export function getUserListByWorkstationId(params: { workstationId: number }) {
  if (!params || !params.workstationId) {
    return Promise.resolve([]);
  }
  return requestClient.get<SystemUserApi.UserSimple[]>(
    '/mes/md-workstation-worker/list-user-by-workstation',
    { params },
  );
}
/** æ–°å¢žå·¥ä½œç«™äººåŠ›èµ„æº */
export function createWorkstationWorker(
  data: MesMdWorkstationWorkerApi.WorkstationWorker,
src/api/mes/pd/document/index.ts
@@ -17,6 +17,8 @@
    documentVersion?: string;
    /** æ–‡ä»¶å */
    fileName?: string;
    /** æ–‡ä»¶åœ°å€ */
    fileUrl?: string;
    /** ä¸Šä¼ äººï¼ˆç”¨æˆ·ID) */
    uploadUserId?: number;
    /** ä¸Šä¼ äººåç§° */
src/api/mes/pd/project/index.ts
@@ -32,6 +32,10 @@
    remark?: string;
    /** åˆ›å»ºæ—¶é—´ */
    createTime?: number;
    /** BPM æµç¨‹å®žä¾‹ç¼–号 */
    processInstanceId?: string;
    /** å¾…审核文件数(auditStatus=0 çš„审核记录数) */
    pendingAuditCount?: number;
  }
  export interface PageParams extends PageParam {
@@ -87,13 +91,20 @@
  });
}
/** å®¡æ ¸ï¼ˆå¾…审核→待归档) */
export function auditPdProject(id: number) {
  return requestClient.put('/mes/pd/project/audit', undefined, {
    params: { id },
/** æäº¤å®¡æ‰¹ï¼ˆå¾…审核→审批中) */
export function submitPdProjectApproval(id: number, processDefinitionKey: string) {
  return requestClient.put('/mes/pd/project/submit-approval', undefined, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å¾—å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getPdProjectApproveProcessList() {
  return requestClient.get<Array<{ id: string; name: string; key: string }>>(
    '/mes/pd/project/approve-process-list',
  );
}
/** å½’档(待归档→已归档),后端自动创建归档台账记录 */
export function archivePdProject(id: number) {
  return requestClient.put('/mes/pd/project/archive', undefined, {
src/api/mes/pro/feedback/index.ts
@@ -1,8 +1,23 @@
import type { PageParam, PageResult } from '@vben/request';
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesProFeedbackApi {
  export interface FeedbackSubmitReqVO {
    id: number;
    produceDate?: string;
    expireDate?: string;
    receiptDate?: string;
    vendorId?: number;
    clientId?: number;
    purchaseOrderCode?: string;
    salesOrderCode?: string;
    toolId?: number;
    moldId?: number;
    lotNumber?: string;
    qualityStatus?: number;
  }
  /** MES ç”Ÿäº§æŠ¥å·¥ */
  export interface Feedback {
    id?: number;
@@ -47,6 +62,21 @@
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº
    createTime?: number; // åˆ›å»ºæ—¶é—´
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ç¼–号
    processDefinitionKey?: string; // BPM æµç¨‹å®šä¹‰ Key
    paramValues?: ParamValue[];
  }
  export interface ParamValue {
    detailId: number;
    value?: string;
    // ---- å›žæ˜¾å­—段(报工详情/列表返回) ----
    paramCode?: string;
    paramName?: string;
    paramType?: number;
    unitMeasureName?: string;
    paramValue?: string;
    required?: boolean;
  }
  /** MES ç”Ÿäº§æŠ¥å·¥åˆ†é¡µæŸ¥è¯¢å‚æ•° */
@@ -100,8 +130,8 @@
}
/** æäº¤ç”Ÿäº§æŠ¥å·¥ */
export function submitFeedback(id: number) {
  return requestClient.put(`/mes/pro/feedback/submit?id=${id}`);
export function submitFeedback(data: MesProFeedbackApi.FeedbackSubmitReqVO) {
  return requestClient.put(`/mes/pro/feedback/submit`, data);
}
/** é©³å›žç”Ÿäº§æŠ¥å·¥ */
@@ -146,9 +176,8 @@
/** å¿«é€ŸæŠ¥å·¥è¯·æ±‚ */
export interface QuickCreateReq {
  taskId: number;
  paramValues?: MesProFeedbackApi.ParamValue[];
  feedbackQuantity: number;
  qualifiedQuantity?: number;
  unqualifiedQuantity?: number;
  remark?: string;
}
@@ -157,14 +186,14 @@
  feedbackId?: number;
  taskStatus?: number;
  workOrderStatus?: number;
  /** æŠ¥å·¥åŽå‰©ä½™å¯æŠ¥å·¥æ•°é‡ */
  reportableQuantity?: number;
}
/** æ‰¹é‡æŠ¥å·¥è¯·æ±‚ */
export interface BatchCreateReq {
  taskIds: number[];
  feedbackQuantity: number;
  qualifiedQuantity?: number;
  unqualifiedQuantity?: number;
  remark?: string;
}
src/api/mes/pro/mps/index.ts
@@ -80,9 +80,9 @@
}
/** åˆ é™¤ MPS */
export function deleteMps(ids: number[]) {
export function deleteMps(id: number) {
  return requestClient.delete('/mes/pro-mps/delete', {
    params: { ids: ids.join(',') },
    params: { id },
  });
}
src/api/mes/pro/processdesign/processparam/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
import { requestClient } from '#/api/request';
export namespace MesProProcessParamApi {
  /** å·¥åº-工艺参数绑定 */
  export interface ProcessParam {
    id?: number;
    processId?: number;
    processParamId?: number;
    paramCode?: string;
    paramName?: string;
    paramType?: number;
    unitMeasureId?: number;
    unitMeasureName?: string;
    required?: boolean;
    sort?: number;
    remark?: string;
  }
  /** ä¿å­˜ç»‘定项 */
  export interface SaveItem {
    processParamId: number;
    required?: boolean;
    sort?: number;
    remark?: string;
  }
}
/** æŸ¥è¯¢å·¥åºç»‘定的工艺参数列表 */
export function getProcessParamListByProcess(processId: number) {
  return requestClient.get<MesProProcessParamApi.ProcessParam[]>(
    '/mes/pro/process-param/list-by-process',
    { params: { processId } },
  );
}
/** å…¨é‡è¦†ç›–保存工序绑定的工艺参数 */
export function updateProcessParams(
  processId: number,
  items: MesProProcessParamApi.SaveItem[],
) {
  return requestClient.post('/mes/pro/process-param/update-bindings', {
    processId,
    items,
  });
}
/** æŸ¥è¯¢æŸå·¥è‰ºå‚数被哪些工序绑定 */
export function getProcessListByParam(paramId: number) {
  return requestClient.get<
    { id: number; code?: string; name?: string; attention?: string }[]
  >('/mes/pro/process-param/list-by-param', { params: { paramId } });
}
src/api/mes/pro/route/process/index.ts
@@ -15,6 +15,7 @@
    id?: number;
    routeId?: number;
    processId?: number;
    paramTemplateId?: number;
    processCode?: string;
    processName?: string;
    sort?: number;
src/api/mes/pro/task/index.ts
@@ -1,8 +1,21 @@
import type { PageParam, PageResult } from '@vben/request';
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesProTaskApi {
  export interface PauseRecord {
    id: number;
    pauseTime: string;
    pauseUserId: number;
    pauseUserName: string;
    reason: string;
    remark?: string;
    resumeTime?: string;
    resumeUserId?: number;
    resumeUserName?: string;
    taskId: number;
  }
  /** MES ç”Ÿäº§ä»»åŠ¡ */
  export interface Task {
    id?: number;
@@ -16,6 +29,7 @@
    workstationName?: string; // å·¥ä½œç«™åç§°
    routeId?: number; // å·¥è‰ºè·¯çº¿ç¼–号
    processId?: number; // å·¥åºç¼–号
    paramTemplateId?: number; // å·¥è‰ºå‚数模板编号
    processName?: string; // å·¥åºåç§°
    itemId?: number; // äº§å“ç‰©æ–™ç¼–号
    itemCode?: string; // äº§å“ç¼–码
@@ -41,6 +55,10 @@
    checkFlag?: boolean; // æ˜¯å¦è´¨æ£€ï¼ˆæ´¾ç”Ÿè‡ªå·¥è‰ºè·¯çº¿å·¥åºï¼‰
    backflushFlag?: boolean; // æ˜¯å¦å€’冲(派生自工艺路线工序)
    remark?: string; // å¤‡æ³¨
    pauseRecords?: PauseRecord[]; // åœå·¥è®°å½•
    reportableQuantity?: number; // å¯æŠ¥å·¥æ•°é‡
    assigneeUserIds?: number[]; // æŠ¥å·¥äºº
    feedbackApprove?: boolean; // æ˜¯å¦æŠ¥å·¥å®¡æ‰¹ï¼ˆæ´¾ç”Ÿè‡ªå·¥å•)
  }
  /** MES ç”Ÿäº§ä»»åŠ¡åˆ†é¡µæŸ¥è¯¢å‚æ•° */
@@ -174,3 +192,20 @@
    { params: { workstationId, quantity } },
  );
}
/** ç”Ÿäº§ä»»åŠ¡åœå·¥è¯·æ±‚å‚æ•° */
export interface MesProTaskPauseReqVO {
  id: number;
  reason: string;
  remark?: string;
}
/** ç”Ÿäº§ä»»åŠ¡åœå·¥ */
export function pauseTask(data: MesProTaskPauseReqVO) {
  return requestClient.put('/mes/pro/task/pause', data);
}
/** ç”Ÿäº§ä»»åŠ¡å¤å·¥ */
export function resumeTask(data: { id: number }) {
  return requestClient.put('/mes/pro/task/resume', data);
}
src/api/mes/pro/workorder/index.ts
@@ -1,4 +1,4 @@
import type { PageParam, PageResult } from '@vben/request';
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
@@ -34,8 +34,15 @@
    finishDate?: number; // å®Œæˆæ—¶é—´
    cancelDate?: number; // å–消时间
    status?: number; // è®¢å•状态
    scheduleStatus?: number; // æŽ’产状态
    productionStatus?: number; // ç”Ÿäº§çŠ¶æ€
    processProgressList?: ProcessProgress[]; // å·¥åºç”Ÿäº§è¿›åº¦
    completionProgress?: number; // å®Œæˆè¿›åº¦
    remark?: string; // å¤‡æ³¨
    createTime?: number; // åˆ›å»ºæ—¶é—´
    feedbackApprove?: boolean; // æ˜¯å¦æŠ¥å·¥å®¡æ‰¹
    processDefinitionKey?: string; // æŠ¥å·¥å®¡æ‰¹æµç¨‹å®šä¹‰ Key
    feedbackRole?: string; // æŠ¥å·¥è§’色
  }
  /** MES ç”Ÿäº§è®¢å•分页查询参数 */
@@ -56,6 +63,14 @@
  return requestClient.get<PageResult<MesProWorkOrderApi.WorkOrder>>(
    '/mes/pro/work-order/page',
    { params },
  );
}
/** èŽ·å¾—ç”Ÿäº§å·¥å•è°ƒåº¦çŠ¶æ€åˆ†é¡µ */
export function getWorkOrderSchedulePage(params: MesProWorkOrderApi.PageParams) {
  return requestClient.get<PageResult<MesProWorkOrderApi.WorkOrder>>(
      '/mes/pro/work-order/schedule-page',
      { params },
  );
}
@@ -147,3 +162,10 @@
    `/mes/pro/work-order/check-auto-finish/${id}`,
  );
}
/** èŽ·å¾—ç”Ÿäº§è®¢å•å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getWorkOrderApproveProcessList() {
  return requestClient.get<Array<{ id: string; name: string; key: string }>>(
    '/mes/pro/work-order/approve-process-list',
  );
}
src/api/mes/pro/workorder/process/index.ts
@@ -19,6 +19,7 @@
    processId?: number; // å·¥åºç¼–号
    processCode?: string; // å·¥åºç¼–码
    processName?: string; // å·¥åºåç§°
    paramTemplateId?: number; // å·¥è‰ºå‚数模板编号
    sort?: number; // åºå·
    nextProcessId?: number; // ä¸‹ä¸€é“工序编号
    nextProcessName?: string; // ä¸‹ä¸€é“工序名称
src/api/mes/pro/workorderparamrecord/index.ts
@@ -13,6 +13,7 @@
    unitMeasureId?: number;
    unitMeasureName?: string;
    paramValue?: string;
    required?: boolean;
    remark?: string;
  }
src/api/mes/qc/template/item/index.ts
@@ -21,6 +21,21 @@
  }
}
/** è´¨æ£€æ–¹æ¡ˆé€‰é¡¹ï¼ˆæ–¹æ¡ˆ-产品关联) */
export namespace MesQcTemplateItemOptionApi {
  export interface Option {
    id?: number; // äº§å“å…³è”编号
    templateId?: number; // è´¨æ£€æ–¹æ¡ˆ ID
    templateCode?: string; // è´¨æ£€æ–¹æ¡ˆç¼–号
    templateName?: string; // è´¨æ£€æ–¹æ¡ˆåç§°
    quantityCheck?: number; // æœ€ä½Žæ£€æµ‹æ•°
    quantityUnqualified?: number; // æœ€å¤§ä¸åˆæ ¼æ•°
    criticalRate?: number; // æœ€å¤§è‡´å‘½ç¼ºé™·çŽ‡ï¼ˆ%)
    majorRate?: number; // æœ€å¤§ä¸¥é‡ç¼ºé™·çŽ‡ï¼ˆ%)
    minorRate?: number; // æœ€å¤§è½»å¾®ç¼ºé™·çŽ‡ï¼ˆ%)
  }
}
/** æŸ¥è¯¢äº§å“å…³è”分页 */
export function getTemplateItemPage(
  params: PageParam & { templateId?: number },
@@ -52,3 +67,16 @@
export function deleteTemplateItem(id: number) {
  return requestClient.delete(`/mes/qc/template/item/delete?id=${id}`);
}
/** æ ¹æ®ç‰©æ–™æˆ–工单/任务和检测种类获取可用质检方案列表 */
export function getTemplateItemOptionList(params: {
  itemId?: number;
  qcType: number;
  taskId?: number;
  workOrderId?: number;
}) {
  return requestClient.get<MesQcTemplateItemOptionApi.Option[]>(
    '/mes/qc/template/item/list-by-item-and-type',
    { params },
  );
}
src/api/mes/wm/batch/index.ts
@@ -56,6 +56,56 @@
    expireDate?: string[]; // è¿‡æœŸæ—¥æœŸ
    receiptDate?: string[]; // å…¥åº“日期
  }
  /** MES æ‰¹æ¬¡é‡‡è´­æ¥æºï¼ˆé‡‡è´­å…¥åº“单 + IQC) */
  export interface PurchaseSource {
    receiptCode?: string; // é‡‡è´­å…¥åº“单号
    receiptDate?: Date; // å…¥åº“日期
    purchaseOrderCode?: string; // é‡‡è´­è®¢å•编号
    vendorId?: number; // ä¾›åº”商 ID
    vendorCode?: string; // ä¾›åº”商编码
    vendorName?: string; // ä¾›åº”商名称
    iqcCode?: string; // IQC æ£€éªŒå•号
    checkResult?: number; // IQC æ£€éªŒç»“论(字典 mes_qc_check_result)
    itemId?: number; // ç‰©æ–™ ID
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    batchCode?: string; // æ‰¹æ¬¡å·
    receivedQuantity?: number; // å…¥åº“数量
  }
  /** MES æ‰¹æ¬¡é”€å”®åŽ»å‘ï¼ˆé”€å”®å‡ºåº“å• + OQC) */
  export interface SalesTarget {
    salesCode?: string; // é”€å”®å‡ºåº“单号
    salesDate?: Date; // å‡ºåº“日期
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    clientId?: number; // å®¢æˆ· ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    oqcCode?: string; // OQC æ£€éªŒå•号
    checkResult?: number; // OQC æ£€éªŒç»“论(字典 mes_qc_check_result)
    itemId?: number; // ç‰©æ–™ ID
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    batchCode?: string; // æ‰¹æ¬¡å·
    quantity?: number; // å‡ºåº“数量
    qualityStatus?: number; // è´¨é‡çŠ¶æ€ï¼ˆå­—å…¸ mes_wm_quality_status)
  }
  /** MES æ‰¹æ¬¡å·¥åºæµè½¬ï¼ˆäº§å‡º/投入事件) */
  export interface ProcessTrace {
    eventType?: 'OUT' | 'IN'; // äº‹ä»¶ç±»åž‹ï¼šOUT-产出 / IN-投入
    workOrderId?: number; // ç”Ÿäº§å·¥å• ID
    workOrderCode?: string; // ç”Ÿäº§å·¥å•编号
    processId?: number; // å·¥åº ID
    processCode?: string; // å·¥åºç¼–码
    processName?: string; // å·¥åºåç§°
    batchCode?: string; // æ‰¹æ¬¡å·
    quantity?: number; // æ•°é‡
    qualityStatus?: number; // è´¨é‡çŠ¶æ€ï¼ˆå­—å…¸ mes_wm_quality_status),仅产出侧有值
    eventTime?: Date; // äº‹ä»¶æ—¶é—´
    processSort?: number; // å·¥åºåºå·
  }
}
/** æŸ¥è¯¢æ‰¹æ¬¡è¯¦æƒ… */
@@ -88,3 +138,34 @@
    { params: { code } },
  );
}
/** æ‰¹æ¬¡é‡‡è´­æ¥æºï¼ˆé‡‡è´­å…¥åº“单 + IQC) */
export function getPurchaseSourceList(code: string) {
  return requestClient.get<MesWmBatchApi.PurchaseSource[]>(
    '/mes/wm/batch/purchase-sources',
    { params: { code } },
  );
}
/** æ‰¹æ¬¡é”€å”®åŽ»å‘ï¼ˆé”€å”®å‡ºåº“å• + OQC) */
export function getSalesTargetList(code: string) {
  return requestClient.get<MesWmBatchApi.SalesTarget[]>(
    '/mes/wm/batch/sales-targets',
    { params: { code } },
  );
}
/** æ‰¹æ¬¡å·¥åºæµè½¬ï¼ˆäº§å‡º/投入事件) */
export function getProcessTraceList(code: string) {
  return requestClient.get<MesWmBatchApi.ProcessTrace[]>(
    '/mes/wm/batch/process-trace',
    { params: { code } },
  );
}
/** æ ¹æ® SN ç åæŸ¥æ‰¹æ¬¡ */
export function getBatchBySn(sn: string) {
  return requestClient.get<MesWmBatchApi.Batch | null>('/mes/wm/batch/get-by-sn', {
    params: { sn },
  });
}
src/api/mes/wm/materialstock/index.ts
@@ -64,6 +64,29 @@
  });
}
/** åº“存现有量导入结果 */
export interface ImportResult {
  /** æ–°å»ºåº“存记录成功的行,元素格式「物料编码-仓库」 */
  createNames?: string[];
  /** å·²å­˜åœ¨åº“存记录调整成功的行,元素格式「物料编码-仓库」 */
  updateNames?: string[];
  /** å¯¼å…¥å¤±è´¥çš„记录,key ä¸ºã€Œç‰©æ–™ç¼–码-仓库」,value ä¸ºå¤±è´¥åŽŸå›  */
  failureNames?: Record<string, string>;
}
/** ä¸‹è½½åº“存现有量导入模板 */
export function importMaterialStockTemplate() {
  return requestClient.download('/mes/wm/material-stock/import-template');
}
/** å¯¼å…¥åº“存现有量 */
export function importMaterialStock(file: File) {
  return requestClient.upload<MesWmMaterialStockApi.ImportResult>(
    '/mes/wm/material-stock/import',
    { file },
  );
}
/** åº“存调整(自定义出入库)- å…¥åº“ */
export interface MaterialStockAdjustInParams {
  itemId: number; // ç‰©æ–™ID
src/api/mes/wm/productissue/line/index.ts
@@ -17,6 +17,16 @@
    batchId?: number; // æ‰¹æ¬¡ç¼–号
    remark?: string; // å¤‡æ³¨
  }
  /** é¢†æ–™å‡ºåº“单可领物料(当前工单 BOM ç‰©æ–™ï¼‰ */
  export interface BomItem {
    itemId?: number; // ç‰©æ–™ç¼–号
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    itemSpecification?: string; // è§„格型号
    unitMeasureName?: string; // å•位名称
    requiredQuantity?: number; // å·¥å• BOM éœ€æ±‚量
  }
}
/** æŸ¥è¯¢é¢†æ–™å‡ºåº“单行分页 */
@@ -51,3 +61,19 @@
export function deleteProductIssueLine(id: number) {
  return requestClient.delete(`/mes/wm/product-issue-line/delete?id=${id}`);
}
/** æŸ¥è¯¢é¢†æ–™å‡ºåº“单可领物料列表(当前工单 BOM ç‰©æ–™ï¼‰ */
export function getBomItemList(issueId: number) {
  return requestClient.get<MesWmProductIssueLineApi.BomItem[]>(
    '/mes/wm/product-issue-line/bom-item-list',
    { params: { issueId } },
  );
}
/** æŸ¥è¯¢å¯é€€æ–™çš„领料单行列表(按生产订单过滤,仅返回已完成领料的记录) */
export function getReturnableIssueLines(workOrderId: number) {
  return requestClient.get<MesWmProductIssueLineApi.ProductIssueLine[]>(
      '/mes/wm/product-issue-line/returnable-list',
      { params: { workOrderId } },
  );
}
src/api/mes/wm/returnissue/line/index.ts
@@ -7,6 +7,7 @@
  export interface ReturnIssueLine {
    id?: number; // è¡Œç¼–号
    issueId?: number; // é€€æ–™å•编号
    issueLineId?: number; // é¢†æ–™å•行编号(关联来源领料记录)
    itemId?: number; // ç‰©æ–™ç¼–号
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
src/api/mes/wm/returnvendor/index.ts
@@ -14,7 +14,7 @@
    vendorCode?: string; // ä¾›åº”商编码
    vendorName?: string; // ä¾›åº”商名称
    vendorNickname?: string; // ä¾›åº”商简称
    returnDate?: number; // é€€è´§æ—¥æœŸ
    returnDate?: number | string; // é€€è´§æ—¥æœŸ
    returnReason?: string; // é€€è´§åŽŸå› 
    transportCode?: string; // è¿å•号
    transportTelephone?: string; // è”系电话
src/api/srm/supplier/index.ts
@@ -31,6 +31,11 @@
  return requestClient.get<SrmSupplierApi.SupplierVO[]>('/srm/supplier/simple-list');
}
/** èŽ·å–ä¾›åº”å•†æ¡£æ¡ˆåˆ—è¡¨ï¼ˆä¸è¿‡æ»¤å‡†å…¥çŠ¶æ€ï¼‰ */
export function getSupplierList() {
  return requestClient.get<SrmSupplierApi.SupplierVO[]>('/srm/supplier/list');
}
/** èŽ·å–ä¾›åº”å•†è¯¦æƒ… */
export function getSupplier(id: number) {
  return requestClient.get<SrmSupplierApi.SupplierVO>(`/srm/supplier/get?id=${id}`);
@@ -50,3 +55,21 @@
export function deleteSupplier(id: number) {
  return requestClient.delete(`/srm/supplier/delete?id=${id}`);
}
/** å¯¼å‡ºä¾›åº”商档案 Excel(按列表查询条件) */
export function exportSupplierExcel(params: any) {
  return requestClient.download('/srm/supplier/export-excel', { params });
}
/** ä¸‹è½½ä¾›åº”商档案导入模板 */
export function getSupplierImportTemplate() {
  return requestClient.download('/srm/supplier/get-import-template');
}
/** å¯¼å…¥ä¾›åº”商档案 Excel */
export function importSupplierExcel(data: {
  file: File;
  updateSupport: boolean;
}) {
  return requestClient.upload('/srm/supplier/import-excel', data);
}
src/api/srm/supplierApply/index.ts
@@ -8,27 +8,32 @@
    applyNo?: string;
    supplierId?: number;
    supplierName: string;
    contactName?: string;
    contactMobile?: string;
    contactEmail?: string;
    supplierType?: string;
    companyNature?: string;
    registeredCapital?: number;
    businessLicenseNo?: string;
    taxNo?: string;
    address?: string;
    bankName?: string;
    bankAccount?: string;
    taxNo?: string;
    businessLicense?: string;
    taxCertificate?: string;
    orgCodeCertificate?: string;
    contactName?: string;
    contactMobile?: string;
    contactEmail?: string;
    applyUserName?: string;
    applyTime?: string;
    applyStatus?: number;
    applyStatusLabel?: string;
    auditComment?: string;
    processInstanceId?: string;
    remark?: string;
    createTime?: string;
  }
  export interface ReviewVO {
    id: number;
    passed: boolean;
    comment?: string;
  /** ååŒåŠžå…¬å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ProcessDefinition {
    id: string;
    key: string;
    name: string;
  }
}
@@ -57,22 +62,16 @@
  return requestClient.delete(`/srm/supplier-apply/delete?id=${id}`);
}
/** æäº¤å®¡æ ¸ */
export function submitApply(id: number) {
  return requestClient.put(`/srm/supplier-apply/submit?id=${id}`);
/** èŽ·å–å®¡æ‰¹æµç¨‹åˆ—è¡¨ï¼ˆååŒåŠžå…¬å·²å‘å¸ƒæµç¨‹ï¼‰ */
export function getApproveProcessList() {
  return requestClient.get<SrmSupplierApplyApi.ProcessDefinition[]>(
    '/srm/supplier-apply/approve-process-list',
  );
}
/** é‡‡è´­å®¡æ ¸ */
export function reviewPurchase(data: SrmSupplierApplyApi.ReviewVO) {
  return requestClient.put('/srm/supplier-apply/review-purchase', data);
}
/** è´¨é‡å®¡æ ¸ */
export function reviewQuality(data: SrmSupplierApplyApi.ReviewVO) {
  return requestClient.put('/srm/supplier-apply/review-quality', data);
}
/** è´¢åŠ¡å®¡æ ¸ */
export function reviewFinance(data: SrmSupplierApplyApi.ReviewVO) {
  return requestClient.put('/srm/supplier-apply/review-finance', data);
/** æäº¤å‡†å…¥ç”³è¯·å®¡æ‰¹ï¼ˆå¯åŠ¨ååŒåŠžå…¬æµç¨‹ï¼‰ */
export function submitApply(id: number, processDefinitionKey: string) {
  return requestClient.put('/srm/supplier-apply/submit', null, {
    params: { id, processDefinitionKey },
  });
}
src/api/srm/supplierCertificate/index.ts
@@ -1,5 +1,6 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace SrmSupplierCertificateApi {
@@ -16,6 +17,8 @@
    status?: number;
    remark?: string;
    createTime?: string;
    blobIds?: number[];
    attachmentList?: SystemStorageApi.StorageBlob[];
  }
}
src/api/srm/tender/index.ts
@@ -1,5 +1,6 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace SrmTenderApi {
@@ -23,6 +24,8 @@
    purchaseOrderNo?: string;
    biddingMode?: string;
    createTime?: string;
    blobIds?: number[];
    attachmentList?: SystemStorageApi.StorageBlob[];
  }
  export interface TenderMaterialVO {
@@ -105,6 +108,7 @@
    id: number;
    tenderDesc?: string;
    qualificationRequirements?: string;
    blobIds?: number[];
  }
}
@@ -194,6 +198,11 @@
  return requestClient.put(`/srm/tender-bid/withdraw?id=${id}`);
}
export function updateBidTotal(id: number, bidTotalAmount?: number | null) {
  const params = bidTotalAmount == null ? '' : `&bidTotalAmount=${bidTotalAmount}`;
  return requestClient.put(`/srm/tender-bid/update-total?id=${id}${params}`);
}
// ========== æŠ¥ä»·ç®¡ç† ==========
export function getQuoteList(bidId: number) {
src/api/system/company-config/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
import { requestClient } from '#/api/request';
export namespace SystemCompanyConfigApi {
  /** å…¬å¸å“ç‰Œä¿¡æ¯ */
  export interface CompanyConfig {
    id?: number;
    companyName?: string;
    logoUrl?: string;
    logoBlobId?: number;
    loginTitle?: string;
    loginDescription?: string;
  }
}
/** èŽ·å¾—å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆå…ç™»å½•å…¬å¼€ï¼Œç™»å½•é¡µ / åº”用启动时使用) */
export async function getCompanyConfig() {
  return requestClient.get<SystemCompanyConfigApi.CompanyConfig | null>(
    '/system/company-config/get',
  );
}
/** ä¿å­˜å…¬å¸å“ç‰Œä¿¡æ¯ */
export async function saveCompanyConfig(
  data: SystemCompanyConfigApi.CompanyConfig,
) {
  return requestClient.post<number>('/system/company-config/save', data);
}
src/api/system/database/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
import { requestClient } from '#/api/request';
function downloadBlob(blob: Blob, headers?: any) {
  let filename = 'backup.sql';
  if (headers) {
    const disposition = headers['content-disposition'] || headers['Content-Disposition'];
    if (disposition && disposition.indexOf('filename=') !== -1) {
      const matches = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
      if (matches != null && matches[1]) {
        filename = matches[1].replace(/['"]/g, '');
      }
    }
  }
  const url = window.URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.style.display = 'none';
  link.href = url;
  link.setAttribute('download', decodeURIComponent(filename));
  document.body.append(link);
  link.click();
  link.remove();
  window.URL.revokeObjectURL(url);
}
/** ä¸‹è½½æ•°æ®åº“备份文件 */
export function backupDatabase(onDownloadProgress?: (progressEvent: any) => void) {
  return requestClient.request('/system/database/backup', {
    method: 'GET',
    responseType: 'blob',
    timeout: 300000, // 5分钟
    onDownloadProgress,
    responseReturn: 'raw',
  }).then((res: any) => {
    const blob = res.data || res;
    if (blob instanceof Blob) {
      downloadBlob(blob, res.headers);
    }
    return res;
  }).catch((err: any) => {
    if (err instanceof Blob) {
      downloadBlob(err);
      return err;
    } else if (err?.response?.data instanceof Blob) {
      downloadBlob(err.response.data, err.response.headers);
      return err;
    }
    throw err;
  });
}
src/api/system/dept/index.ts
@@ -50,3 +50,16 @@
export async function deleteDeptList(ids: number[]) {
  return requestClient.delete(`/system/dept/delete-list?ids=${ids.join(',')}`);
}
/** ä¸‹è½½éƒ¨é—¨å¯¼å…¥æ¨¡æ¿ */
export function importDeptTemplate() {
  return requestClient.download('/system/dept/get-import-template');
}
/** å¯¼å…¥éƒ¨é—¨ */
export function importDept(file: File, updateSupport: boolean) {
  return requestClient.upload('/system/dept/import', {
    file,
    updateSupport,
  });
}
src/api/system/post/index.ts
@@ -61,3 +61,16 @@
    params,
  });
}
/** ä¸‹è½½å²—位导入模板 */
export function importPostTemplate() {
  return requestClient.download('/system/post/get-import-template');
}
/** å¯¼å…¥å²—位 */
export function importPost(file: File, updateSupport: boolean) {
  return requestClient.upload('/system/post/import', {
    file,
    updateSupport,
  });
}
src/api/system/storage/index.ts
@@ -45,6 +45,17 @@
  );
}
/** å…¬å…±æ–‡ä»¶ä¸Šä¼ ï¼ˆæ°¸ä¹…有效,返回永久公开 previewURL,用于登录页等免登录展示的场景) */
export function publicUploadFile(files: globalThis.File[]) {
  const formData = new FormData();
  files.forEach((file) => formData.append('files', file));
  return requestClient.post<SystemStorageApi.StorageBlob[]>(
    '/system/storage-blob/public-upload',
    formData,
    { headers: { 'Content-Type': 'multipart/form-data' } },
  );
}
/** æŸ¥è¯¢é™„件列表 */
export function listAttachments(params: SystemStorageApi.AttachmentListParams) {
  return requestClient.get<SystemStorageApi.StorageBlob[]>(
src/api/system/user/index.ts
@@ -114,3 +114,10 @@
    params: { nickname },
  });
}
/** é€šè¿‡è§’色标识获取用户精简信息列表 */
export function getSimpleUserListByRoleCode(code: Object) {
  return requestClient.get<SystemUserApi.UserSimple[]>('/system/user/list-by-role-code', {
    params:  code ,
  });
}
src/bootstrap.ts
@@ -18,6 +18,9 @@
import App from './app.vue';
import { router } from './router';
import { getCompanyConfig } from '#/api/system/company-config';
import { applyCompanyConfig } from '#/utils/company-config';
async function bootstrap(namespace: string) {
  // åˆå§‹åŒ–组件适配器
  await initComponentAdapter();
@@ -49,6 +52,17 @@
  // é…ç½® pinia-store
  await initStores(app, { namespace });
  // åŠ è½½å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆä»ŽåŽç«¯ï¼Œå…ç™»å½•æŽ¥å£ï¼‰ï¼Œè”åŠ¨ç™»å½•é¡µ/菜单栏/网页标题。
  // æ³¨ï¼šæ­¤æ—¶ pinia å°šæœªåœ¨ç»„件外激活,这里用纯函数,不依赖 store。
  try {
    const companyConfig = await getCompanyConfig();
    if (companyConfig) {
      applyCompanyConfig(companyConfig);
    }
  } catch {
    // åŽç«¯ä¸å¯ç”¨æˆ–未配置,保持编译期默认
  }
  // å®‰è£…权限指令
  registerAccessDirective(app);
@@ -71,7 +85,8 @@
    if (preferences.app.dynamicTitle) {
      const routeTitle = router.currentRoute.value.meta?.title;
      const pageTitle =
        (routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
        (routeTitle ? `${$t(routeTitle)} - ` : '') +
        preferences.copyright.companyName;
      useTitle(pageTitle);
    }
  });
src/components/form-create/helpers.ts
@@ -28,7 +28,10 @@
}
/** è§£ç è¡¨å• Conf */
export function decodeConf(conf: string) {
export function decodeConf(conf?: string | null) {
  if (!conf) {
    return {};
  }
  return formCreate.parseJson(conf);
}
@@ -43,9 +46,9 @@
}
/** è§£ç è¡¨å• Fields */
export function decodeFields(fields: string[]) {
export function decodeFields(fields?: string[] | null) {
  const rule: Rule[] = [];
  fields.forEach((item) => {
  (fields || []).forEach((item) => {
    rule.push(formCreate.parseJson(item));
  });
  return rule;
src/components/upload/file-upload.vue
@@ -26,7 +26,7 @@
  disabled: false,
  drag: false,
  helpText: '',
  maxSize: 2,
  maxSize: 50,
  maxNumber: 1,
  accept: () => [],
  multiple: false,
@@ -292,14 +292,14 @@
      :progress="{ showInfo: true }"
      :show-upload-list="{
        showPreviewIcon: true,
        showRemoveIcon: true,
        showRemoveIcon: !disabled,
        showDownloadIcon,
      }"
      @remove="handleRemove"
      @preview="handlePreview"
      @reject="handleExceed"
    >
      <div v-if="drag" class="upload-drag-area">
      <div v-if="drag && !disabled" class="upload-drag-area">
        <p class="ant-upload-drag-icon">
          <IconifyIcon icon="lucide:cloud-upload" />
        </p>
@@ -308,7 +308,9 @@
          æ”¯æŒ{{ accept.join('/') }}格式文件,不超过{{ maxSize }}MB
        </p>
      </div>
      <div v-else-if="fileList && fileList.length < maxNumber">
      <div
        v-else-if="!disabled && fileList && fileList.length < maxNumber"
      >
        <Button>
          <IconifyIcon icon="lucide:cloud-upload" />
          {{ $t('ui.upload.upload') }}
src/components/upload/image-upload.vue
@@ -32,7 +32,7 @@
  disabled: false,
  listType: 'picture-card',
  helpText: '',
  maxSize: 2,
  maxSize: 50,
  maxNumber: 1,
  accept: () => defaultImageAccepts,
  multiple: false,
src/components/upload/use-upload.ts
@@ -103,12 +103,18 @@
  ) {
    const formData = new FormData();
    formData.append('files', file);
    const blobs = await requestClient.post('/system/storage-blob/upload', formData, {
    // ä½¿ç”¨å…¬å…±ä¸Šä¼ æŽ¥å£ï¼Œè¿”回永久公开 previewURL
    const blobs = await requestClient.post('/system/storage-blob/public-upload', formData, {
      onUploadProgress,
      headers: { 'Content-Type': 'multipart/form-data' },
    });
    const blob = Array.isArray(blobs) ? blobs[0] : blobs;
    return blob ? { url: blob.url || blob.previewURL, name: blob.name, id: blob.id } : {};
    // å­˜å‚¨å®Œæ•´ previewURL(含 publicKey),确保可访问
    return blob ? {
      url: blob.previewURL || blob.url,
      name: blob.name || blob.originalFilename,
      id: blob.id
    } : {};
  }
  return {
src/layouts/auth.vue
@@ -1,16 +1,28 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
import { useCompanyConfigStore } from '#/store/company-config';
declare const __APP_LOGO__: string;
const appName = computed(() => preferences.app.name);
const logo = computed(() => __APP_LOGO__ || preferences.logo.source);
const companyConfigStore = useCompanyConfigStore();
onMounted(() => {
  // ç™»å½•页展示 loginTitle:启动加载走的是纯函数(不填 store),进入登录页时拉取一次
  companyConfigStore.loadFromServer();
});
const appName = computed(() => preferences.copyright.companyName);
// ç™»å½•页 logo ä¼˜å…ˆå–数据库配置(preferences.logo.source),未配置时回退编译期默认 __APP_LOGO__
const logo = computed(() => preferences.logo.source || __APP_LOGO__);
const logoDark = computed(() => preferences.logo.sourceDark);
// ç™»å½•页标题/描述使用 i18n å›ºå®šæ–‡æ¡ˆï¼Œä¸éšå…¬å¸é…ç½®å˜åŒ–
const pageTitle = computed(() => $t('authentication.pageTitle'));
const pageDescription = computed(() => $t('authentication.pageDesc'));
</script>
<template>
@@ -18,8 +30,8 @@
    :app-name="appName"
    :logo="logo"
    :logo-dark="logoDark"
    :page-description="$t('authentication.pageDesc')"
    :page-title="$t('authentication.pageTitle')"
    :page-description="pageDescription"
    :page-title="pageTitle"
  >
    <!-- è‡ªå®šä¹‰å·¥å…·æ  -->
    <!-- <template #toolbar></template> -->
src/main.ts
@@ -5,6 +5,7 @@
declare const __APP_LOGO__: string;
declare const __APP_FAVICON__: string;
declare const __APP_NAME__: string;
/**
 * åº”用初始化完成之后再进行页面加载渲染
@@ -27,6 +28,13 @@
  if (__APP_LOGO__) {
    preferencesManager.updatePreferences({ logo: { source: __APP_LOGO__ } });
  }
  // å¼ºåˆ¶è¦†ç›–公司名称,绕过 localStorage ç¼“å­˜
  if (__APP_NAME__) {
    preferencesManager.updatePreferences({
      app: { name: __APP_NAME__ },
      copyright: { companyName: __APP_NAME__ },
    });
  }
  if (__APP_FAVICON__) {
    const link = document.querySelector('link[rel="icon"]');
    if (link) {
src/packages/@core/base/icons/src/lucide.ts
@@ -27,6 +27,7 @@
  CloudUpload,
  Copy,
  CornerDownLeft,
  DatabaseBackup,
  Download,
  Ellipsis,
  Eraser,
src/packages/@core/ui-kit/shadcn-ui/src/components/segmented/segmented.vue
@@ -5,17 +5,22 @@
import { TabsTrigger } from 'reka-ui';
import { cn } from '../../../../../base/shared/src/utils';
import { Tabs, TabsContent, TabsList } from '../../ui';
import TabsIndicator from './tabs-indicator.vue';
interface Props {
  defaultValue?: string;
  tabs?: SegmentedItem[];
  /** æ˜¯å¦å…è®¸æ¨ªå‘滚动:tab è¾ƒå¤šæ—¶å¯ç”¨ï¼Œå„ tab æŒ‰å†…容宽度排列,超出部分可滑动 */
  scrollable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  defaultValue: '',
  tabs: () => [],
  scrollable: false,
});
const activeTab = defineModel<string>();
@@ -25,12 +30,18 @@
});
const tabsStyle = computed(() => {
  if (props.scrollable) {
    return undefined;
  }
  return {
    'grid-template-columns': `repeat(${props.tabs.length}, minmax(0, 1fr))`,
  };
});
const tabsIndicatorStyle = computed(() => {
  if (props.scrollable) {
    return undefined;
  }
  return {
    width: `${(100 / props.tabs.length).toFixed(0)}%`,
  };
@@ -45,14 +56,22 @@
  <Tabs v-model="activeTab" :default-value="getDefaultValue">
    <TabsList
      :style="tabsStyle"
      class="bg-accent outline-heavy! relative grid w-full outline-2!"
      :class="
        cn(
          'bg-accent outline-heavy! relative outline-2!',
          // å¯æ»šåŠ¨æ¨¡å¼ï¼šå·¦å¯¹é½ï¼ˆè¦†ç›– reka-ui çš„ justify-center,否则内容超宽时左侧被挤出且无法滚动)、全宽、横向滚动
          scrollable
            ? 'flex! w-full! overflow-x-auto justify-start!'
            : 'grid w-full',
        )
      "
    >
      <TabsIndicator :style="tabsIndicatorStyle" />
      <TabsIndicator v-if="!scrollable" :style="tabsIndicatorStyle" />
      <template v-for="tab in tabs" :key="tab.value">
        <TabsTrigger
          :value="tab.value"
          :class="activeClass(tab.value)"
          class="hover:text-primary z-20 inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap disabled:pointer-events-none disabled:opacity-50"
          class="hover:text-primary z-20 inline-flex shrink-0 items-center justify-center rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap disabled:pointer-events-none disabled:opacity-50"
        >
          {{ tab.label }}
        </TabsTrigger>
src/packages/constants/src/biz-erp-enum.ts
@@ -2,6 +2,7 @@
  PURCHASE_ORDER: 10,
  PURCHASE_IN: 11,
  PURCHASE_RETURN: 12,
  PURCHASE_INVOICE: 13,
  SALE_ORDER: 20,
  SALE_OUT: 21,
  SALE_RETURN: 22,
src/packages/constants/src/biz-mes-enum.ts
@@ -104,6 +104,26 @@
  ABNORMAL: 2,
} as const;
/** MES è®¡é‡å™¨å…·çŠ¶æ€æžšä¸¾ */
export const MesDvMeteringStatusEnum = {
  IN_USE: 1, // åœ¨ç”¨
  STOPPED: 2, // åœç”¨
  SCRAPPED: 3, // æŠ¥åºŸ
} as const;
/** MES è®¡é‡æ£€æµ‹ç»“论枚举 */
export const MesDvMeteringCheckResultEnum = {
  QUALIFIED: 1, // åˆæ ¼
  UNQUALIFIED: 2, // ä¸åˆæ ¼
} as const;
/** MES è®¡é‡å™¨å…·åˆ°æœŸçŠ¶æ€æžšä¸¾ */
export const MesDvMeteringExpireStatusEnum = {
  OVERDUE: 1, // å·²è¿‡æœŸ
  WITHIN: 2, // å³å°†åˆ°æœŸ
  NORMAL: 3, // æ­£å¸¸
} as const;
/** MES ç»´ä¿®å·¥å•状态枚举 */
export const MesDvRepairStatusEnum = {
  PREPARE: MesOrderStatusConstants.DRAFT,
@@ -222,6 +242,13 @@
  CONFIRMED: 1, // å·²ç¡®è®¤
  FINISHED: 2, // å·²å®Œæˆ
  CANCELED: 3, // å·²å–消
} as const;
/** MES ç”Ÿäº§å·¥å•生产状态枚举(对齐后端 MesProWorkOrderProductionStatusEnum) */
export const MesProWorkOrderProductionStatusEnum = {
  WAITING: 0, // å¾…生产
  PRODUCING: 1, // ç”Ÿäº§ä¸­
  FINISHED: 2, // ç”Ÿäº§å®Œæˆ
} as const;
/** MES å·¥å•类型枚举 */
@@ -516,11 +543,12 @@
  RIGHT: 2, // å³è¡¥é½
} as const;
/** MES äº§å“è®¾è®¡é¡¹ç›®çŠ¶æ€æžšä¸¾ï¼ˆçº¿æ€§æµè½¬ï¼šå¾…å‘å¸ƒâ†’å¾…ä¸Šä¼ â†’å¾…å®¡æ ¸â†’å¾…å½’æ¡£â†’å·²å½’æ¡£ï¼‰ */
/** MES äº§å“è®¾è®¡é¡¹ç›®çŠ¶æ€æžšä¸¾ï¼ˆçº¿æ€§æµè½¬ï¼šå¾…å‘å¸ƒâ†’å¾…ä¸Šä¼ â†’å¾…å®¡æ ¸â†’å®¡æ‰¹ä¸­â†’å¾…å½’æ¡£â†’å·²å½’æ¡£ï¼‰ */
export const MesPdProjectStatusEnum = {
  PENDING_PUBLISH: 0, // å¾…发布
  PENDING_UPLOAD: 1, // å¾…上传
  PENDING_AUDIT: 2, // å¾…审核
  APPROVING: 5, // å®¡æ‰¹ä¸­
  PENDING_ARCHIVE: 3, // å¾…å½’æ¡£
  ARCHIVED: 4, // å·²å½’æ¡£
} as const;
src/packages/constants/src/biz-wms-enum.ts
@@ -57,14 +57,14 @@
  return prefix + result;
}
/** ç›˜ç‚¹æ¡ä»¶ç±»åž‹æžšä¸¾ */
/** ç›˜ç‚¹æ¡ä»¶ç±»åž‹æžšä¸¾ï¼ˆä¸ŽåŽç«¯ MesBizTypeConstants / å­—典值保持一致) */
export const WlsStockTakingParamTypeEnum = {
  WAREHOUSE: 1, // ä»“库
  LOCATION: 2, // åº“区
  AREA: 3, // åº“位
  ITEM: 4, // ç‰©æ–™
  BATCH: 5, // æ‰¹æ¬¡
  QUALITY_STATUS: 6, // è´¨é‡çŠ¶æ€
  WAREHOUSE: 102, // ä»“库
  LOCATION: 103, // åº“区
  AREA: 104, // åº“位
  ITEM: 600, // ç‰©æ–™
  BATCH: 107, // æ‰¹æ¬¡
  QUALITY_STATUS: 900, // è´¨é‡çŠ¶æ€
} as const;
/** ç›˜ç‚¹ç±»åž‹æžšä¸¾ */
src/packages/constants/src/dict-enum.ts
@@ -123,6 +123,10 @@
  CRM_PRODUCT_STATUS: 'crm_product_status', // CRM å•†å“çŠ¶æ€
  CRM_PRODUCT_UNIT: 'crm_product_unit', // CRM äº§å“å•位
  CRM_RECEIVABLE_RETURN_TYPE: 'crm_receivable_return_type', // CRM å›žæ¬¾çš„还款方式
  CRM_INVOICE_TYPE: 'crm_invoice_type', // CRM å‘票类型
  CRM_SALES_TARGET_PERIOD_TYPE: 'crm_sales_target_period_type', // CRM é”€å”®ç›®æ ‡å‘¨æœŸç±»åž‹
  CRM_SALES_TARGET_DIMENSION: 'crm_sales_target_dimension', // CRM é”€å”®ç›®æ ‡ç»´åº¦
  CRM_SALES_TARGET_STATUS: 'crm_sales_target_status', // CRM é”€å”®ç›®æ ‡çŠ¶æ€
} as const;
/** ========== AFTERSALES - å”®åŽæœåŠ¡æ¨¡å— ========== */
@@ -230,8 +234,12 @@
  MES_DV_REPAIR_RESULT: 'mes_dv_repair_result', // MES ç»´ä¿®ç»“æžœ
  MES_DV_CHECK_RECORD_STATUS: 'mes_dv_check_record_status', // MES ç‚¹æ£€è®°å½•状态
  MES_DV_CHECK_RESULT: 'mes_dv_check_result', // MES ç‚¹æ£€ç»“æžœ
  MES_DV_METERING_CATEGORY: 'mes_dv_metering_category', // MES è®¡é‡å™¨å…·ç±»åˆ«
  MES_DV_METERING_STATUS: 'mes_dv_metering_status', // MES è®¡é‡å™¨å…·çŠ¶æ€
  MES_DV_METERING_CHECK_RESULT: 'mes_dv_metering_check_result', // MES è®¡é‡æ£€æµ‹ç»“论
  MES_PRO_LINK_TYPE: 'mes_pro_link_type', // MES å·¥åºå…³ç³»ç±»åž‹
  MES_PRO_WORK_ORDER_STATUS: 'mes_pro_work_order_status', // MES ç”Ÿäº§å·¥å•状态
  ORDER_PRODUCTION_STATUS: 'order_production_status', // ç”Ÿäº§è®¢å•状态
  MES_PRO_WORK_ORDER_TYPE: 'mes_pro_work_order_type', // MES å·¥å•类型
  MES_PRO_WORK_ORDER_SOURCE_TYPE: 'mes_pro_work_order_source_type', // MES å·¥å•来源类型
  MES_PRO_TASK_STATUS: 'mes_pro_task_status', // MES ç”Ÿäº§ä»»åŠ¡çŠ¶æ€
@@ -320,6 +328,10 @@
  HRM_SALARY_STATUS: 'hrm_salary_status', // HRM è–ªèµ„状态
  HRM_HANDOVER_STATUS: 'hrm_handover_status', // HRM äº¤æŽ¥çŠ¶æ€
  HRM_PAYMENT_METHOD: 'hrm_payment_method', // HRM å‘放方式
  HRM_CONTRACT_TYPE: 'hrm_contract_type', // HRM åˆåŒç±»åž‹
  HRM_CONTRACT_TERM_TYPE: 'hrm_contract_term_type', // HRM åˆåŒæœŸé™ç±»åž‹
  HRM_CONTRACT_STATUS: 'hrm_contract_status', // HRM åˆåŒçŠ¶æ€
  HRM_CONTRACT_TERMINATE_STATUS: 'hrm_contract_terminate_status', // HRM åˆåŒè§£é™¤ç»ˆæ­¢çŠ¶æ€
} as const;
/** å­—典类型枚举 - ç»Ÿä¸€å¯¼å‡º */
src/packages/effects/layouts/src/basic/header/header.vue
@@ -9,6 +9,7 @@
import { VbenFullScreen, VbenIconButton } from '../../../../../@core/ui-kit/shadcn-ui/src';
import {
  DatabaseBackupButton,
  GlobalSearch,
  LanguageToggle,
  PreferencesButton,
@@ -56,6 +57,12 @@
  }
  // åå¥½è®¾ç½®å¿«æ·åŠŸèƒ½
  if (preferencesButtonPosition.value.header) {
    // æ•°æ®åº“备份按钮
    list.push({
      index: REFERENCE_VALUE + 5,
      name: 'database-backup',
    });
    list.push({
      index: REFERENCE_VALUE + 10,
      name: 'preferences',
@@ -188,6 +195,9 @@
          />
        </template>
        <template v-else-if="slot.name === 'database-backup'">
          <DatabaseBackupButton class="mr-1" />
        </template>
        <template v-else-if="slot.name === 'preferences'">
          <PreferencesButton
            class="mr-1"
src/packages/effects/layouts/src/basic/layout.vue
@@ -291,7 +291,7 @@
        :collapsed="logoCollapsed"
        :src="preferences.logo.source"
        :src-dark="preferences.logo.sourceDark"
        :text="preferences.app.name"
        :text="preferences.copyright.companyName"
        :theme="logoTheme"
        @click="clickLogo"
      >
@@ -382,7 +382,7 @@
      <VbenLogo
        v-if="preferences.logo.enable"
        :fit="preferences.logo.fit"
        :text="preferences.app.name"
        :text="preferences.copyright.companyName"
        :theme="sidebarThemeSub"
      >
        <template v-if="$slots['logo-text']" #text>
src/packages/effects/layouts/src/widgets/database-backup/database-backup.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
<script lang="ts" setup>
import { DatabaseBackup } from '../../../../../icons/src';
import { VbenIconButton } from '../../../../../@core/ui-kit/shadcn-ui/src';
import { useVbenModal } from '../../../../../@core/ui-kit/popup-ui/src';
import { backupDatabase } from '../../../../../../api/system/database';
import { ref } from 'vue';
const isDownloading = ref(false);
const downloadProgress = ref(0);
const [Modal, modalApi] = useVbenModal({
  onConfirm: async () => {
    modalApi.close();
    try {
      isDownloading.value = true;
      downloadProgress.value = 0;
      await backupDatabase((progressEvent) => {
        if (progressEvent.total) {
          downloadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
        }
      });
    } catch (error: any) {
      // Ignore if it's the Blob being thrown by the response interceptor
      if (!(error instanceof Blob) && !(error?.response?.data instanceof Blob)) {
        console.error('Backup failed:', error);
      }
    } finally {
      isDownloading.value = false;
      downloadProgress.value = 0;
    }
  },
});
function handleOpen() {
  modalApi.open();
}
</script>
<template>
  <div class="relative">
    <VbenIconButton
      v-access:code="['system:database:backup']"
      class="hover:animate-[shrink_0.3s_ease-in-out]"
      @click="handleOpen"
    >
      <DatabaseBackup class="size-4 text-foreground" />
    </VbenIconButton>
    <Modal
      title="提示"
      content-class="px-8 min-h-10"
      footer-class="border-none mb-3 mr-3"
      header-class="border-none"
    >
      ç¡®è®¤è¦ä¸‹è½½æ•°æ®åº“的备份文件吗?
    </Modal>
    <teleport to="body">
      <div
        v-if="isDownloading"
        class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
      >
        <div class="flex flex-col items-center space-y-4 rounded-lg bg-background p-6 shadow-lg">
          <div class="size-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
          <p class="text-lg font-medium text-foreground">正在下载备份: {{ downloadProgress }}%</p>
        </div>
      </div>
    </teleport>
  </div>
</template>
src/packages/effects/layouts/src/widgets/database-backup/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
export { default as DatabaseBackupButton } from './database-backup.vue';
src/packages/effects/layouts/src/widgets/index.ts
@@ -12,3 +12,4 @@
export * from './theme-toggle';
export * from './timezone';
export * from './user-dropdown';
export * from './database-backup';
src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { message } from 'ant-design-vue';
import { Button, Input, Upload } from 'ant-design-vue';
import { onMounted, ref } from 'vue';
// æ³¨æ„ï¼šæœ¬æ–‡ä»¶ä½äºŽ @vben çš„ packages å†…部,无法使用应用层别名 #/,需用相对路径(与同目录 custom.vue ä¿æŒä¸€è‡´ï¼‰
import { publicUploadFile } from '../../../../../../../../api/system/storage';
import { $t } from '../../../../../../../locales/src';
import { useCompanyConfigStore } from '../../../../../../../../store/company-config';
defineOptions({
  name: 'PreferenceCompanyConfig',
});
const companyConfigStore = useCompanyConfigStore();
const companyName = ref(companyConfigStore.companyName);
const loginTitle = ref(companyConfigStore.loginTitle);
const loginDescription = ref(companyConfigStore.loginDescription);
const logoUrl = ref(companyConfigStore.logoUrl);
const uploading = ref(false);
// æ‰“开区块时从后端拉取最新公司配置并回填表单(store åˆå€¼å¯èƒ½ä¸ºç©ºï¼Œå› å¯åŠ¨åŠ è½½èµ°çš„æ˜¯çº¯å‡½æ•°ï¼‰
onMounted(async () => {
  await companyConfigStore.loadFromServer();
  companyName.value = companyConfigStore.companyName;
  loginTitle.value = companyConfigStore.loginTitle;
  loginDescription.value = companyConfigStore.loginDescription;
  logoUrl.value = companyConfigStore.logoUrl;
});
/** ä¸Šä¼  logo(永久公开,供登录页免登录展示) */
async function handleBeforeUpload(file: File) {
  uploading.value = true;
  try {
    const blobs = await publicUploadFile([file]);
    const blob = blobs?.[0];
    if (blob?.previewURL || blob?.url) {
      logoUrl.value = blob.previewURL || blob.url || '';
      message.success($t('preferences.company.uploadSuccess'));
    } else {
      message.error($t('preferences.company.uploadFailed'));
    }
  } catch {
    message.error($t('preferences.company.uploadFailed'));
  } finally {
    uploading.value = false;
  }
  // è¿”回 false,阻止组件默认上传(已通过接口上传)
  return false;
}
async function handleSave() {
  if (!companyName.value.trim()) {
    message.warning($t('preferences.company.companyNameRequired'));
    return;
  }
  try {
    await companyConfigStore.save({
      companyName: companyName.value.trim(),
      logoUrl: logoUrl.value || undefined,
      loginTitle: loginTitle.value,
      loginDescription: loginDescription.value,
    });
    message.success($t('preferences.company.saveSuccess'));
  } catch {
    message.error($t('preferences.company.saveFailed'));
  }
}
</script>
<template>
  <div class="flex flex-col">
    <!-- å…¬å¸åç§° -->
    <div class="my-2 flex w-full items-center justify-between gap-4 rounded-md px-2 py-2">
      <span class="shrink-0 text-sm text-foreground/80">
        {{ $t('preferences.company.companyName') }}
      </span>
      <Input
        v-model:value="companyName"
        :placeholder="$t('preferences.company.companyNamePlaceholder')"
        class="w-41.25"
        allow-clear
      />
    </div>
    <!-- ç™»å½•页标题 -->
    <div class="my-2 flex w-full items-center justify-between gap-4 rounded-md px-2 py-2">
      <span class="shrink-0 text-sm text-foreground/80">
        {{ $t('preferences.company.loginTitle') }}
      </span>
      <Input
        v-model:value="loginTitle"
        :placeholder="$t('preferences.company.loginTitlePlaceholder')"
        class="w-41.25"
        allow-clear
      />
    </div>
    <!-- ç™»å½•页描述 -->
    <div class="my-2 flex w-full items-center justify-between gap-4 rounded-md px-2 py-2">
      <span class="shrink-0 text-sm text-foreground/80">
        {{ $t('preferences.company.loginDescription') }}
      </span>
      <Input
        v-model:value="loginDescription"
        :placeholder="$t('preferences.company.loginDescriptionPlaceholder')"
        class="w-41.25"
        allow-clear
      />
    </div>
    <!-- å…¬å¸ Logo -->
    <div class="my-2 flex w-full items-center justify-between gap-4 rounded-md px-2 py-2">
      <span class="shrink-0 text-sm text-foreground/80">
        {{ $t('preferences.company.logo') }}
      </span>
      <div class="flex items-center gap-2">
        <Upload
          :show-upload-list="false"
          accept="image/*"
          :before-upload="handleBeforeUpload"
        >
          <Button size="small" :loading="uploading">
            {{ $t('preferences.company.uploadLogo') }}
          </Button>
        </Upload>
        <img
          v-if="logoUrl"
          :src="logoUrl"
          alt="company-logo"
          class="h-9 w-9 rounded border border-border object-contain"
        />
        <span v-else class="text-xs text-muted-foreground">
          {{ $t('preferences.company.logoEmpty') }}
        </span>
      </div>
    </div>
    <div v-if="uploading" class="px-2 pb-1 text-xs text-muted-foreground">
      {{ $t('preferences.company.uploading') }}
    </div>
    <!-- ä¿å­˜ -->
    <div class="mt-3 px-2">
      <Button type="primary" class="w-full" @click="handleSave">
        {{ $t('preferences.company.save') }}
      </Button>
    </div>
  </div>
</template>
src/packages/effects/layouts/src/widgets/preferences/blocks/index.ts
@@ -1,4 +1,5 @@
export { default as Block } from './block.vue';
export { default as Company } from './company/company.vue';
export { default as Custom } from './custom/custom.vue';
export { default as Animation } from './general/animation.vue';
export { default as General } from './general/general.vue';
src/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue
@@ -43,6 +43,7 @@
  Breadcrumb,
  BuiltinTheme,
  ColorMode,
  Company,
  Content,
  Copyright,
  Custom,
@@ -256,6 +257,10 @@
      label: $t('preferences.general'),
      value: 'general',
    },
    {
      label: $t('preferences.company.title'),
      value: 'company',
    },
  ];
  if (showCustomTab.value) {
@@ -352,6 +357,7 @@
        <VbenSegmented
          v-model="activeTab"
          :tabs="tabs"
          scrollable
          :class="{
            'sticky-tabs-header': appEnableStickyPreferencesNavigationBar,
          }"
@@ -526,6 +532,11 @@
              />
            </Block>
          </template>
          <template #company>
            <Block :title="$t('preferences.company.title')">
              <Company />
            </Block>
          </template>
          <template #custom>
            <Block :title="customTabTitle">
              <Custom
src/packages/locales/src/langs/en-US/preferences.json
@@ -229,5 +229,24 @@
        "placeholder": "Please enter the default report title"
      }
    }
  },
  "company": {
    "title": "Company Info",
    "companyName": "Company Name",
    "companyNamePlaceholder": "Please enter company name",
    "companyNameRequired": "Company name is required",
    "loginTitle": "Login Page Title",
    "loginTitlePlaceholder": "Enter login page title (optional)",
    "loginDescription": "Login Page Description",
    "loginDescriptionPlaceholder": "Enter login page description (optional)",
    "logo": "Company Logo",
    "uploadLogo": "Upload Logo",
    "uploadSuccess": "Logo uploaded successfully",
    "uploadFailed": "Logo upload failed",
    "uploading": "Uploading...",
    "logoEmpty": "No logo set",
    "save": "Save",
    "saveSuccess": "Company info saved successfully",
    "saveFailed": "Failed to save company info"
  }
}
src/packages/locales/src/langs/zh-CN/preferences.json
@@ -229,5 +229,24 @@
        "placeholder": "请输入报表默认标题"
      }
    }
  },
  "company": {
    "title": "公司信息",
    "companyName": "公司名称",
    "companyNamePlaceholder": "请输入公司名称",
    "companyNameRequired": "公司名称不能为空",
    "loginTitle": "登录页标题",
    "loginTitlePlaceholder": "请输入登录页标题(可选)",
    "loginDescription": "登录页描述",
    "loginDescriptionPlaceholder": "请输入登录页描述(可选)",
    "logo": "公司 Logo",
    "uploadLogo": "上传 Logo",
    "uploadSuccess": "Logo ä¸Šä¼ æˆåŠŸ",
    "uploadFailed": "Logo ä¸Šä¼ å¤±è´¥",
    "uploading": "上传中...",
    "logoEmpty": "未设置 Logo",
    "save": "保存",
    "saveSuccess": "公司信息保存成功",
    "saveFailed": "公司信息保存失败"
  }
}
src/preferences.ts
@@ -4,6 +4,7 @@
} from './packages/preferences/src';
declare const __APP_LOGO__: string;
declare const __APP_NAME__: string;
interface WebAntdPreferencesExtension {
  defaultTableSize: number;
@@ -22,7 +23,7 @@
  app: {
    /** åŽç«¯è·¯ç”±æ¨¡å¼ */
    accessMode: 'backend',
    name: import.meta.env.VITE_APP_TITLE,
    name: __APP_NAME__ || import.meta.env.VITE_APP_TITLE,
    enableRefreshToken: true,
  },
  footer: {
@@ -31,7 +32,7 @@
    fixed: false,
  },
  copyright: {
    companyName: import.meta.env.VITE_APP_TITLE,
    companyName: __APP_NAME__ || import.meta.env.VITE_APP_TITLE,
    companySiteLink: '#',
  },
  logo: {
src/router/guard.ts
@@ -60,8 +60,7 @@
      if (to.path === LOGIN_PATH && accessStore.accessToken) {
        return decodeURIComponent(
          (to.query?.redirect as string) ||
            userStore.userInfo?.homePath ||
            preferences.app.defaultHomePath,
            '/analytics',
        );
      }
      return true;
@@ -132,7 +131,7 @@
    userStore.setUserRoles(userRoles);
    const redirectPath = (from.query.redirect ??
      (to.path === preferences.app.defaultHomePath
        ? userInfo?.homePath || preferences.app.defaultHomePath
        ? '/analytics'
        : to.fullPath)) as string;
    return {
src/router/routes/modules/crm.ts
@@ -83,6 +83,15 @@
        },
        component: () => import('#/views/crm/product/detail/index.vue'),
      },
      {
        path: 'invoice/detail/:id',
        name: 'CrmInvoiceDetail',
        meta: {
          title: '开票详情',
          activePath: '/crm/invoice',
        },
        component: () => import('#/views/crm/invoice/detail/index.vue'),
      },
    ],
  },
];
src/router/routes/modules/mes.ts
@@ -29,6 +29,16 @@
        },
        component: () => import('#/views/mes/pd/project/detail/index.vue'),
      },
      {
        path: 'pro/feedback/detail/:id',
        name: 'MesProFeedbackDetail',
        meta: {
          title: '生产报工详情',
          activePath: '/mes/pro/feedback',
        },
        props: true,
        component: () => import('#/views/mes/pro/feedback/detail/index.vue'),
      },
    ],
  },
];
src/store/auth.ts
@@ -86,9 +86,7 @@
          // oxlint-disable-next-line no-unused-expressions
          onSuccess
            ? await onSuccess?.()
            : await router.push(
                userInfo.homePath || preferences.app.defaultHomePath,
              );
            : await router.push('/analytics');
        }
        if (userInfo?.nickname) {
src/store/company-config.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import {
  getCompanyConfig,
  saveCompanyConfig as saveCompanyConfigApi,
} from '#/api/system/company-config';
import { applyCompanyConfig } from '#/utils/company-config';
/**
 * å…¬å¸å“ç‰Œä¿¡æ¯å…¨å±€é…ç½®
 *
 * å¯åŠ¨æ—¶ä»ŽåŽç«¯åŠ è½½ï¼Œä¿å­˜æ—¶å†™å›žåŽç«¯ï¼›å¹¶å°†å…¬å¸å/logo åŒæ­¥åˆ° preferences,
 * ä»Žè€Œè”动登录页、菜单栏、网页标题等所有消费点。
 */
export const useCompanyConfigStore = defineStore('companyConfig', () => {
  const companyName = ref('');
  const logoUrl = ref('');
  const loginTitle = ref('');
  const loginDescription = ref('');
  /** å°†å…¬å¸ä¿¡æ¯åº”用到 preferences,联动登录页/菜单栏/标题 */
  function applyToPreferences() {
    applyCompanyConfig({
      companyName: companyName.value,
      logoUrl: logoUrl.value,
    });
  }
  /** ä»ŽåŽç«¯åŠ è½½å…¬å¸é…ç½®å¹¶åº”ç”¨ */
  async function loadFromServer() {
    try {
      const config = await getCompanyConfig();
      if (config) {
        companyName.value = config.companyName ?? '';
        logoUrl.value = config.logoUrl ?? '';
        loginTitle.value = config.loginTitle ?? '';
        loginDescription.value = config.loginDescription ?? '';
        applyToPreferences();
      }
    } catch {
      // åŽç«¯ä¸å¯ç”¨æˆ–未配置,保持编译期默认
    }
  }
  /** ä¿å­˜å…¬å¸é…ç½®åˆ°åŽç«¯å¹¶åº”用 */
  async function save(data: {
    companyName: string;
    logoUrl?: string;
    logoBlobId?: number;
    loginTitle?: string;
    loginDescription?: string;
  }) {
    await saveCompanyConfigApi(data);
    companyName.value = data.companyName;
    logoUrl.value = data.logoUrl ?? '';
    loginTitle.value = data.loginTitle ?? '';
    loginDescription.value = data.loginDescription ?? '';
    applyToPreferences();
  }
  return {
    companyName,
    logoUrl,
    loginTitle,
    loginDescription,
    applyToPreferences,
    loadFromServer,
    save,
  };
});
src/store/index.ts
@@ -1 +1,2 @@
export * from './auth';
export * from './company-config';
src/types/app.d.ts
@@ -1,2 +1,3 @@
declare const __APP_LOGO__: string;
declare const __APP_FAVICON__: string;
declare const __APP_NAME__: string;
src/utils/company-config.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
import { preferencesManager } from '@vben/preferences';
/** å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆä¸ŽåŽç«¯ system_company_config è¡¨å­—段对应) */
export interface CompanyConfigData {
  companyName?: string;
  logoUrl?: string;
  loginTitle?: string;
  loginDescription?: string;
}
/**
 * å°†å…¬å¸å“ç‰Œä¿¡æ¯åº”用到全局:
 * - å…¬å¸å â†’ preferences.app.name / copyright.companyName(联动菜单栏、登录页、网页标题、版权)
 * - logo â†’ preferences.logo.source(联动登录页、侧边栏 logo)
 * - æœ‰å…¬å¸åæ—¶ï¼Œè®¾ç½® document.title(基础标题,dynamicTitle å¼€å¯æ—¶è·¯ç”±æ ‡é¢˜åœ¨æ­¤åŸºç¡€ä¸Šæ‹¼æŽ¥ï¼‰
 */
export function applyCompanyConfig(config: CompanyConfigData) {
  const updates: Record<string, any> = {};
  if (config.logoUrl) {
    updates.logo = { source: config.logoUrl };
    // åŒæ­¥ç½‘页标题图标(favicon)为上传的 logo
    const faviconLink = document.querySelector('link[rel="icon"]');
    faviconLink?.setAttribute('href', config.logoUrl);
  }
  if (config.companyName) {
    updates.app = { name: config.companyName };
    updates.copyright = { companyName: config.companyName };
  }
  if (Object.keys(updates).length > 0) {
    preferencesManager.updatePreferences(updates);
  }
  if (config.companyName) {
    document.title = config.companyName;
  }
}
src/utils/index.ts
@@ -30,3 +30,18 @@
  });
  return index;
};
/**
 * åŽ»æŽ‰è¡¨æ ¼åˆ—çš„å›ºå®šï¼ˆfixed)配置
 *
 * ç”¨äºŽã€ŒæŸ¥çœ‹å®¡æ‰¹æ˜Žç»† / è¯¦æƒ…」等只读场景:此时表格容器较窄,
 * å›ºå®šåˆ—会挤占可视宽度,把产品名称、单位、备注等列遮住。
 *
 * @param columns åŽŸå§‹è¡¨æ ¼åˆ—é…ç½®
 */
export function withoutFixedColumns<T>(columns?: null | T[]): T[] {
  return (columns ?? []).map((column) => {
    const { fixed: _fixed, ...rest } = column as Recordable<any>;
    return rest as unknown as T;
  });
}
src/views/aftersales/problem/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,252 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AftersalesProblemApi } from '#/api/aftersales/problem';
/** è¡¨å•类型 */
export type ProblemFormType = 'create' | 'update' | 'detail';
/** é—®é¢˜ç±»åž‹é€‰é¡¹ï¼ˆå¯¹é½ AfterSaleIssueTypeEnum) */
export const ISSUE_TYPE_OPTIONS = [
  { label: '一般问题', value: 1 },
  { label: '维修问题', value: 2 },
  { label: '退货问题', value: 3 },
];
/** ä¸¥é‡ç¨‹åº¦é€‰é¡¹ */
export const SEVERITY_LEVEL_OPTIONS = [
  { label: '轻微', value: 0 },
  { label: '一般', value: 1 },
  { label: '严重', value: 2 },
  { label: '致命', value: 3 },
];
/** æ ¹å› åˆ†ç±»é€‰é¡¹ï¼ˆå¯¹é½ AfterSaleRootCauseCategoryEnum) */
export const ROOT_CAUSE_CATEGORY_OPTIONS = [
  { label: '设计缺陷', value: 1 },
  { label: '物料缺陷', value: 2 },
  { label: '工艺缺陷', value: 3 },
  { label: '装配缺陷', value: 4 },
  { label: '运输损坏', value: 5 },
  { label: '包装缺陷', value: 6 },
  { label: '使用不当', value: 7 },
  { label: '软件缺陷', value: 8 },
  { label: '其他', value: 9 },
];
/** é—®é¢˜çŠ¶æ€é€‰é¡¹ï¼ˆå¯¹é½ AfterSaleProblemStatusEnum) */
export const PROBLEM_STATUS_OPTIONS = [
  { label: '草稿', value: 0 },
  { label: '已发布', value: 1 },
  { label: '已停用', value: 2 },
];
/** é—®é¢˜åº“表单 */
export function useProblemFormSchema(
  formType: ProblemFormType,
): VbenFormSchema[] {
  const readonly = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'title',
      label: '问题标题',
      component: 'Input',
      componentProps: { placeholder: '请输入问题标题' },
      rules: 'required',
    },
    {
      fieldName: 'issueType',
      label: '问题类型',
      component: 'Select',
      componentProps: {
        options: ISSUE_TYPE_OPTIONS,
        placeholder: '请选择问题类型',
        disabled: readonly,
      },
    },
    {
      fieldName: 'issueCategory',
      label: '问题分类',
      component: 'Input',
      componentProps: {
        placeholder: '如功能故障、外观缺陷',
        disabled: readonly,
      },
    },
    {
      fieldName: 'severityLevel',
      label: '严重程度',
      component: 'Select',
      componentProps: {
        options: SEVERITY_LEVEL_OPTIONS,
        placeholder: '请选择',
        disabled: readonly,
      },
    },
    {
      fieldName: 'rootCauseCategory',
      label: '根因分类',
      component: 'Select',
      componentProps: {
        options: ROOT_CAUSE_CATEGORY_OPTIONS,
        placeholder: '请选择根因分类',
        disabled: readonly,
      },
    },
    {
      fieldName: 'itemCode',
      label: '物料编码',
      component: 'Input',
      componentProps: { placeholder: '来源于工单明细', disabled: readonly },
    },
    {
      fieldName: 'itemName',
      label: '物料名称',
      component: 'Input',
      componentProps: { placeholder: '来源于工单明细', disabled: readonly },
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      component: 'Input',
      componentProps: { placeholder: '来源于工单明细', disabled: readonly },
    },
    {
      fieldName: 'description',
      label: '问题描述',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        rows: 3,
        placeholder: '问题现象与背景',
        disabled: readonly,
      },
    },
    {
      fieldName: 'rootCause',
      label: '根因分析',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        rows: 3,
        placeholder: '缺陷产生的根本原因',
        disabled: readonly,
      },
    },
    {
      fieldName: 'solution',
      label: '解决方案',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        rows: 3,
        placeholder: '处理措施/SOP,供后续工单参考',
        disabled: readonly,
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: { rows: 2, placeholder: '备注', disabled: readonly },
    },
    {
      fieldName: 'blobIds',
      label: '附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** é—®é¢˜åº“搜索表单 */
export function useProblemSearchFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'keyword',
      label: '关键字',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '单号/标题/描述/方案',
      },
    },
    {
      fieldName: 'itemCode',
      label: '物料编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料编码',
      },
    },
    {
      fieldName: 'issueCategory',
      label: '问题分类',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入问题分类',
      },
    },
    {
      fieldName: 'issueType',
      label: '问题类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: ISSUE_TYPE_OPTIONS,
        placeholder: '请选择',
      },
    },
    {
      fieldName: 'rootCauseCategory',
      label: '根因分类',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: ROOT_CAUSE_CATEGORY_OPTIONS,
        placeholder: '请选择',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: PROBLEM_STATUS_OPTIONS,
        placeholder: '请选择状态',
      },
    },
  ];
}
/** é—®é¢˜åº“列表字段 */
export function useProblemGridColumns(): VxeTableGridOptions<AftersalesProblemApi.Problem>['columns'] {
  return [
    { field: 'no', title: '问题单号', minWidth: 170, slots: { default: 'no' } },
    { field: 'title', title: '问题标题', minWidth: 180 },
    { field: 'sourceTicketNo', title: '来源工单', minWidth: 150 },
    { field: 'itemName', title: '物料名称', minWidth: 120 },
    { field: 'itemCode', title: '物料编码', minWidth: 120 },
    { field: 'issueType', title: '问题类型', width: 100, slots: { default: 'issueType' } },
    { field: 'severityLevel', title: '严重程度', width: 100, slots: { default: 'severityLevel' } },
    { field: 'rootCauseCategory', title: '根因分类', width: 100, slots: { default: 'rootCauseCategory' } },
    { field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
    { field: 'ownerUserName', title: '沉淀人', minWidth: 100 },
    { field: 'updateTime', title: '更新时间', width: 170, formatter: 'formatDate' },
    { title: '操作', width: 220, fixed: 'right', slots: { default: 'actions' } },
  ];
}
src/views/aftersales/problem/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,214 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AftersalesProblemApi } from '#/api/aftersales/problem';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteProblem,
  disableProblem,
  getProblemPage,
  publishProblem,
} from '#/api/aftersales/problem';
import {
  PROBLEM_STATUS_OPTIONS,
  ROOT_CAUSE_CATEGORY_OPTIONS,
  useProblemGridColumns,
  useProblemSearchFormSchema,
} from './data';
import ProblemForm from './modules/form.vue';
const statusLabelMap = Object.fromEntries(
  PROBLEM_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
const rootCauseLabelMap = Object.fromEntries(
  ROOT_CAUSE_CATEGORY_OPTIONS.map((o) => [o.value, o.label]),
);
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: ProblemForm,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleDetail(row: AftersalesProblemApi.Problem) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
function handleEdit(row: AftersalesProblemApi.Problem) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
async function handleDelete(row: AftersalesProblemApi.Problem) {
  await deleteProblem(row.id!);
  message.success('删除成功');
  handleRefresh();
}
async function handlePublish(row: AftersalesProblemApi.Problem) {
  await publishProblem(row.id!);
  message.success('发布成功');
  handleRefresh();
}
async function handleDisable(row: AftersalesProblemApi.Problem) {
  await disableProblem(row.id!);
  message.success('停用成功');
  handleRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useProblemSearchFormSchema(),
  },
  gridOptions: {
    columns: useProblemGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getProblemPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<AftersalesProblemApi.Problem>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="售后问题库列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增问题',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['aftersales:problem:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #no="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.no }}
        </Button>
      </template>
      <template #issueType="{ row }">
        <Tag v-if="row.issueType === 1" color="blue">一般问题</Tag>
        <Tag v-else-if="row.issueType === 2" color="orange">维修问题</Tag>
        <Tag v-else-if="row.issueType === 3" color="red">退货问题</Tag>
        <span v-else>-</span>
      </template>
      <template #severityLevel="{ row }">
        <Tag v-if="row.severityLevel === 0" color="green">轻微</Tag>
        <Tag v-else-if="row.severityLevel === 1" color="blue">一般</Tag>
        <Tag v-else-if="row.severityLevel === 2" color="orange">严重</Tag>
        <Tag v-else-if="row.severityLevel === 3" color="red">致命</Tag>
        <span v-else>-</span>
      </template>
      <template #rootCauseCategory="{ row }">
        <span>{{ rootCauseLabelMap[row.rootCauseCategory!] || '-' }}</span>
      </template>
      <template #status="{ row }">
        <Tag
          :color="
            row.status === 1
              ? 'green'
              : row.status === 0
                ? 'default'
                : 'orange'
          "
        >
          {{ statusLabelMap[row.status!] || '-' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '查看',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '编辑',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['aftersales:problem:update'],
              ifShow: row.status === 0,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '发布',
              type: 'link',
              auth: ['aftersales:problem:publish'],
              ifShow: row.status === 0 || row.status === 2,
              popConfirm: {
                title: '确认发布该问题?',
                confirm: handlePublish.bind(null, row),
              },
            },
            {
              label: '停用',
              type: 'link',
              auth: ['aftersales:problem:disable'],
              ifShow: row.status === 1,
              popConfirm: {
                title: '确认停用该问题?',
                confirm: handleDisable.bind(null, row),
              },
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['aftersales:problem:delete'],
              ifShow: row.status === 0,
              popConfirm: {
                title: `确认删除问题【${row.no}】?`,
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/aftersales/problem/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,139 @@
<script lang="ts" setup>
import type { ProblemFormType } from '../data';
import type { AftersalesProblemApi } from '#/api/aftersales/problem';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createProblem,
  getProblem,
  updateProblem,
} from '#/api/aftersales/problem';
import { useProblemFormSchema } from '../data';
defineOptions({ name: 'AftersalesProblemForm' });
const emit = defineEmits(['success']);
const formType = ref<ProblemFormType>('create');
const id = ref<number>();
const submitting = ref(false);
const isEdit = computed(() => formType.value !== 'detail');
const title = computed(() => {
  if (formType.value === 'detail') return '查看售后问题';
  if (id.value) return '编辑售后问题';
  return '新增售后问题';
});
// ä¸»è¡¨å•
const [Form, formApi] = useVbenForm({
  commonConfig: { componentProps: { class: 'w-full' }, formItemClass: 'col-span-1', labelWidth: 100 },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
/** æŠŠé—®é¢˜å¯¹è±¡æ˜ å°„成表单值(附件转为 FileUpload ç»“构) */
function problemToForm(p: AftersalesProblemApi.Problem): Record<string, unknown> {
  const form: Record<string, unknown> = { ...p };
  if (p.attachmentList?.length) {
    form.blobIds = p.attachmentList.map((item) => ({
      uid: String(item.id),
      name: item.name || '',
      url: item.url || '',
      status: 'done',
      id: item.id,
    }));
  }
  return form;
}
/** æŠŠè¡¨å•里的附件值规整成 blobId æ•°ç»„ */
function normalizeBlobIds(value: unknown): number[] {
  const arr = Array.isArray(value) ? value : [];
  return arr
    .map((x) =>
      typeof x === 'number'
        ? x
        : x && typeof x === 'object' && 'id' in (x as Record<string, unknown>)
          ? Number((x as Record<string, unknown>).id)
          : Number(x),
    )
    .filter((n) => Number.isFinite(n));
}
// Modal
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      id.value = undefined;
      return;
    }
    const data = modalApi.getData<{ formType: ProblemFormType; id?: number }>();
    formType.value = data.formType;
    id.value = data.id;
    formApi.setState({ schema: useProblemFormSchema(formType.value) });
    formApi.setDisabled(formType.value === 'detail');
    if (data.id) {
      modalApi.lock();
      try {
        const detail = await getProblem(data.id);
        await formApi.setValues(problemToForm(detail));
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
    }
  },
});
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid) {
    return;
  }
  submitting.value = true;
  try {
    const data = (await formApi.getValues()) as Record<string, unknown>;
    const blobIds = normalizeBlobIds(data.blobIds);
    const payload = { ...data, blobIds } as AftersalesProblemApi.Problem;
    if (id.value) {
      await updateProblem({ ...payload, id: id.value });
    } else {
      await createProblem(payload);
    }
    message.success('操作成功');
    await modalApi.close();
    emit('success');
  } catch (e: unknown) {
    message.error((e as Error).message || '提交失败');
  } finally {
    submitting.value = false;
  }
}
</script>
<template>
  <Modal :title="title" class="w-2/3">
    <Form class="mx-4" />
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
        <Button v-if="isEdit" type="primary" :loading="submitting" @click="handleSubmit">
          ç¡®è®¤
        </Button>
        <Button v-else type="primary" @click="modalApi.close()">关闭</Button>
      </div>
    </template>
  </Modal>
</template>
src/views/aftersales/ticket/detail.vue
@@ -7,7 +7,17 @@
import { Page } from '@vben/common-ui';
import { AftersaleIssueTypeEnum, AftersaleTicketStatusEnum } from '@vben/constants';
import { Button, Card, Descriptions, Input, message, Popconfirm, Select, Tag } from 'ant-design-vue';
import {
  Button,
  Card,
  Descriptions,
  Input,
  message,
  Popconfirm,
  Select,
  Switch,
  Tag,
} from 'ant-design-vue';
import {
  assignTicket,
@@ -17,6 +27,11 @@
  getTicket,
  resolveTicket,
} from '#/api/aftersales/ticket';
import type { AftersalesVisitApi } from '#/api/aftersales/visit';
import {
  createVisit,
  getVisitListByTicketId,
} from '#/api/aftersales/visit';
defineOptions({ name: 'AftersalesTicketDetail' });
@@ -32,6 +47,32 @@
const isPendingReturn = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PENDING_RETURN);
const isPendingRefund = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PENDING_REFUND);
const isResolved = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.RESOLVED);
/** å›žè®¿æ˜¯å¦å·²é€šè¿‡ï¼ˆæ»¡æ„ä¸”未复发) */
const isVisitPassed = computed(() => ticket.value?.visitStatus === 1);
// å›žè®¿è®°å½•
const visits = ref<AftersalesVisitApi.Visit[]>([]);
const showVisitForm = ref(false);
const visitForm = ref<AftersalesVisitApi.CreateVisit>({
  ticketId: 0,
  visitType: 0,
  content: '',
  satisfied: true,
  recurred: false,
});
const visitTypeOptions = [
  { label: '电话回访', value: 0 },
  { label: '上门回访', value: 1 },
  { label: '远程回访', value: 2 },
  { label: '其他', value: 3 },
];
const visitTypeMap: Record<number, string> = {
  0: '电话',
  1: '上门',
  2: '远程',
  3: '其他',
};
// åˆ†é…å¤„理人
const showAssignInput = ref(false);
@@ -86,6 +127,30 @@
  } finally {
    loading.value = false;
  }
}
async function loadVisits() {
  visits.value = await getVisitListByTicketId(ticketId.value);
}
function toggleVisitForm() {
  showVisitForm.value = !showVisitForm.value;
  if (showVisitForm.value) {
    visitForm.value = {
      ticketId: ticketId.value,
      visitType: 0,
      content: '',
      satisfied: true,
      recurred: false,
    };
  }
}
async function handleCreateVisit() {
  await createVisit({ ...visitForm.value, ticketId: ticketId.value });
  message.success('回访登记成功');
  showVisitForm.value = false;
  await Promise.all([loadVisits(), loadTicket()]);
}
function toggleAssignInput() {
@@ -161,6 +226,7 @@
onMounted(() => {
  loadTicket();
  loadVisits();
});
</script>
@@ -195,6 +261,14 @@
        </Descriptions.Item>
        <Descriptions.Item label="负责人">{{ ticket.ownerUserName || '-' }}</Descriptions.Item>
        <Descriptions.Item label="处理人">{{ ticket.handlerUserName || '-' }}</Descriptions.Item>
        <Descriptions.Item label="回访状态">
          <Tag v-if="ticket.visitStatus === 1" color="green">已回访通过</Tag>
          <Tag v-else color="orange">待回访</Tag>
        </Descriptions.Item>
        <Descriptions.Item label="问题沉淀" :span="2">
          <template v-if="ticket.problemNo">{{ ticket.problemNo }}</template>
          <template v-else>-</template>
        </Descriptions.Item>
        <Descriptions.Item label="问题描述" :span="3">
          {{ ticket.issueDescription || '-' }}
        </Descriptions.Item>
@@ -253,6 +327,101 @@
        >
          {{ file.name || '附件-' + file.id }}
        </a>
      </div>
    </Card>
    <!-- å›žè®¿è®°å½• -->
    <Card v-if="ticket" title="回访记录" class="mb-4">
      <template #extra>
        <Button
          v-if="isResolved && !showVisitForm"
          type="primary"
          size="small"
          @click="toggleVisitForm"
        >
          ç™»è®°å›žè®¿
        </Button>
      </template>
      <div v-if="isResolved" class="mb-3 text-sm text-gray-500">
        ç™»è®°ã€Œæ»¡æ„ ä¸” æœªå¤å‘」的回访后,工单才可归档;登记「不满意或复发」会自动退回处理中。
      </div>
      <!-- ç™»è®°å›žè®¿è¡¨å• -->
      <div
        v-if="showVisitForm"
        class="mb-4 flex flex-wrap items-center gap-3 rounded bg-gray-50 p-3"
      >
        <div class="flex items-center gap-1">
          <span class="text-sm text-gray-500">回访方式:</span>
          <Select
            v-model:value="visitForm.visitType"
            :options="visitTypeOptions"
            style="width: 120px"
          />
        </div>
        <div class="flex items-center gap-1">
          <span class="text-sm text-gray-500">回访内容:</span>
          <Input
            v-model:value="visitForm.content"
            placeholder="客户反馈内容"
            style="width: 260px"
          />
        </div>
        <div class="flex items-center gap-1">
          <span class="text-sm text-gray-500">是否满意:</span>
          <Switch
            v-model:checked="visitForm.satisfied"
            checked-children="满意"
            un-checked-children="不满意"
          />
        </div>
        <div class="flex items-center gap-1">
          <span class="text-sm text-gray-500">是否复发:</span>
          <Switch
            v-model:checked="visitForm.recurred"
            checked-children="复发"
            un-checked-children="未复发"
          />
        </div>
        <Button type="primary" size="small" @click="handleCreateVisit">提交</Button>
        <Button size="small" @click="toggleVisitForm">取消</Button>
      </div>
      <!-- å›žè®¿è®°å½•列表 -->
      <div v-if="visits.length === 0" class="text-sm text-gray-400">暂无回访记录</div>
      <div v-else class="overflow-x-auto">
        <table class="w-full border text-sm">
          <thead class="bg-gray-50">
            <tr>
              <th class="border p-2 text-left">回访时间</th>
              <th class="border p-2 text-left">方式</th>
              <th class="border p-2 text-left">回访人</th>
              <th class="border p-2 text-center">满意</th>
              <th class="border p-2 text-center">复发</th>
              <th class="border p-2 text-left">内容</th>
              <th class="border p-2 text-left">备注</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="visit in visits" :key="visit.id">
              <td class="border p-2">{{ visit.visitTime }}</td>
              <td class="border p-2">{{ visitTypeMap[visit.visitType!] ?? '-' }}</td>
              <td class="border p-2">{{ visit.visitorUserName || '-' }}</td>
              <td class="border p-2 text-center">
                <Tag :color="visit.satisfied ? 'green' : 'red'">
                  {{ visit.satisfied ? '满意' : '不满意' }}
                </Tag>
              </td>
              <td class="border p-2 text-center">
                <Tag v-if="visit.recurred" color="orange">复发</Tag>
                <Tag v-else color="default">未复发</Tag>
              </td>
              <td class="border p-2">{{ visit.content || '-' }}</td>
              <td class="border p-2">{{ visit.remark || '-' }}</td>
            </tr>
          </tbody>
        </table>
      </div>
    </Card>
@@ -323,13 +492,13 @@
          <Button type="primary">完结</Button>
        </Popconfirm>
        <!-- å…³é—­ -->
        <!-- å½’档(仅回访通过后可归档;未回访请在“回访记录”里登记) -->
        <Popconfirm
          v-if="isResolved"
          title="确认关闭该工单?关闭后不可恢复。"
          v-if="isResolved && isVisitPassed"
          title="确认归档该工单?归档后自动沉淀一条问题,工单只读不可恢复。"
          @confirm="handleClose"
        >
          <Button type="primary">关闭</Button>
          <Button type="primary">归档</Button>
        </Popconfirm>
        <!-- å–消 -->
src/views/aftersales/ticket/index.vue
@@ -3,6 +3,7 @@
import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { AftersaleTicketStatusEnum } from '@vben/constants';
@@ -16,6 +17,16 @@
import { useTicketGridColumns, useTicketGridFormSchema } from './data';
import Form from './modules/form.vue';
const router = useRouter();
/** æŸ¥çœ‹å·¥å•(进入路由详情页,可登记回访/归档) */
function handleDetail(row: AftersalesTicketApi.Ticket) {
  router.push({
    name: 'AftersalesTicketDetail',
    params: { id: row.id },
  });
}
const sceneType = ref('1');
@@ -38,11 +49,6 @@
/** åˆ›å»ºå·¥å• */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** æŸ¥çœ‹å·¥å• */
function handleDetail(row: AftersalesTicketApi.Ticket) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** ç¼–辑工单 */
src/views/basicData/mdm/data.ts
@@ -333,6 +333,17 @@
      },
    },
    {
      component: 'Switch',
      fieldName: 'syncMes',
      label: '同步到MES',
      defaultValue: true,
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(true),
    },
    {
      component: "RadioGroup",
      fieldName: "status",
      label: "状态",
@@ -597,7 +608,7 @@
    {
      field: "syncedMes",
      title: "同步状态",
      width: 90,
      width: 100,
      fixed: "right",
      slots: { default: "syncedMes" },
    },
@@ -619,3 +630,27 @@
    },
  ];
}
/** ç‰©æ–™å¯¼å…¥çš„表单 */
export function useImportFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'file',
      label: '物料数据',
      component: 'Upload',
      rules: 'required',
      help: '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶',
    },
    {
      fieldName: 'updateSupport',
      label: '是否覆盖',
      component: 'Switch',
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
      help: '是否更新已经存在的物料数据',
    },
  ];
}
src/views/basicData/mdm/index.vue
@@ -8,26 +8,33 @@
import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart, isEmpty } from '#/packages/utils/src';
import { message, Tag } from 'ant-design-vue';
import { message, Switch, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteItem,
  deleteItemList,
  // exportItem,
  exportItem,
  getItemPage,
  updateItemStatus,
  updateItemSync,
} from '#/api/mdm/item';
import { getItemTypeSimpleList } from '#/api/mes/md/item/type';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
defineOptions({ name: 'MdmItem' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -74,15 +81,20 @@
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
// async function handleExport() {
//   const data = await exportItem(await gridApi.formApi.getValues());
//   downloadFileFromBlobPart({ fileName: '物料.xls', source: data });
// }
/** å¯¼å‡ºè¡¨æ ¼ï¼ˆæŒ‰å½“前列表查询条件导出全部命中数据) */
async function handleExport() {
  const data = await exportItem(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '物料数据.xls', source: data });
}
/** åˆ›å»ºç‰©æ–™ */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** å¯¼å…¥ç‰©æ–™ */
function handleImport() {
  importModalApi.open();
}
/** ç¼–辑物料 */
@@ -135,6 +147,23 @@
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const syncingId = ref<number | null>(null);
/** åˆ‡æ¢åŒæ­¥çŠ¶æ€ï¼šå…³é—­å¼€å…³ä¼šä»Ž MES åˆ é™¤ç‰©æ–™ï¼Œéœ€ç¡®è®¤ */
async function handleSyncChange(row: MdmItemApi.Item, checked: boolean) {
  if (!checked) {
    await confirm('关闭同步将从 MES åˆ é™¤è¯¥ç‰©æ–™åŠå…³è”配置(BOM/SOP/SIP/批次配置),确认继续?');
  }
  syncingId.value = row.id!;
  try {
    await updateItemSync(row.id!, checked);
    message.success(checked ? '已同步到 MES' : '已取消同步');
    handleRefresh();
  } finally {
    syncingId.value = null;
  }
}
@@ -193,6 +222,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="物料列表">
      <template #toolbar-tools>
        <TableAction
@@ -204,13 +234,20 @@
              auth: ['mdm:item:create'],
              onClick: handleCreate,
            },
            // {
            //   label: $t('ui.actionTitle.export'),
            //   type: 'primary',
            //   icon: ACTION_ICON.DOWNLOAD,
            //   auth: ['mdm:item:export'],
            //   onClick: handleExport,
            // },
            {
              label: $t('ui.actionTitle.import', ['物料']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mdm:item:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mdm:item:export'],
              onClick: handleExport,
            },
            {
              label: $t('ui.actionTitle.deleteBatch'),
              type: 'primary',
@@ -232,8 +269,13 @@
        <Tag v-else>否</Tag>
      </template>
      <template #syncedMes="{ row }">
        <Tag v-if="row.syncedMes" color="success">已同步</Tag>
        <Tag v-else color="default">未同步</Tag>
        <Switch
          :checked="row.syncedMes"
          :loading="syncingId === row.id"
          checkedChildren="已同步"
          unCheckedChildren="未同步"
          @change="handleSyncChange(row, $event)"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
src/views/basicData/mdm/modules/form.vue
@@ -67,6 +67,8 @@
      modalApi.lock();
      try {
        formData.value = await getItem(data.id);
        // åŒæ­¥çŠ¶æ€æ˜ å°„ï¼šè¯¦æƒ…æŽ¥å£è¿”å›ž syncedMes,表单字段为 syncMes
        formData.value.syncMes = formData.value.syncedMes ?? true;
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
src/views/basicData/mdm/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { h } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, notification, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importItem, importItemTemplate } from '#/api/mdm/item';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    formItemClass: 'col-span-2',
    labelWidth: 120,
  },
  layout: 'horizontal',
  schema: useImportFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      const response = (await importItem(data.file, data.updateSupport)) as any;
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
      const resultData = response?.data || response || {};
      const failureList = resultData.failureList || {};
      if (Object.keys(failureList).length > 0) {
        const errorNodes = Object.entries(failureList).map(([name, reason]) => {
          return h('div', `${name}: ${reason}`);
        });
        notification.warning({
          message: '导入完成,部分物料导入失败',
          description: () => h('div', errorNodes),
          duration: null,
        });
      } else {
        message.success($t('ui.actionMessage.operationSuccess'));
      }
    } finally {
      modalApi.unlock();
    }
  },
});
/** ä¸Šä¼ å‰ */
function beforeUpload(file: FileType) {
  formApi.setFieldValue('file', file);
  return false;
}
/** ä¸‹è½½æ¨¡ç‰ˆ */
async function handleDownload() {
  const data = await importItemTemplate();
  downloadFileFromBlobPart({ fileName: '物料导入模板.xls', source: data });
}
</script>
<template>
  <Modal title="导入物料" class="w-1/3">
    <Form class="mx-4">
      <template #file>
        <div class="w-full">
          <Upload
            :max-count="1"
            accept=".xls,.xlsx"
            :before-upload="beforeUpload"
          >
            <Button type="primary"> é€‰æ‹© Excel æ–‡ä»¶ </Button>
          </Upload>
        </div>
      </template>
    </Form>
    <div class="mx-4 mt-2 mb-4 text-gray-500 text-xs">
      æç¤ºï¼šæ¨¡æ¿ä¸­â€œç‰©æ–™åˆ†ç±»ç¼–码”、“计量单位编码”、“辅单位编码”请填写对应主数据的编码,可留空。
      <div>“是否批次管理”、“是否同步到MES”填 true/false。</div>
      <div>“状态”填 0(启用)/1(禁用)。</div>
    </div>
    <template #prepend-footer>
      <div class="flex flex-auto items-center">
        <Button @click="handleDownload"> ä¸‹è½½å¯¼å…¥æ¨¡æ¿ </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/basicData/mdm/unit/data.ts
@@ -128,4 +128,28 @@
      slots: { default: 'actions' },
    },
  ];
}
/** è®¡é‡å•位导入的表单 */
export function useImportFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'file',
      label: '单位数据',
      component: 'Upload',
      rules: 'required',
      help: '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶',
    },
    {
      fieldName: 'updateSupport',
      label: '是否覆盖',
      component: 'Switch',
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
      help: '是否更新已经存在的单位数据',
    },
  ];
}
src/views/basicData/mdm/unit/index.vue
@@ -3,23 +3,31 @@
import type { MdmUnitApi } from '#/api/mdm/unit';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteUnit,
  exportUnit,
  getUnitPage,
} from '#/api/mdm/unit';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
defineOptions({ name: 'MdmUnit' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -31,6 +39,17 @@
/** åˆ›å»ºè®¡é‡å•位 */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** å¯¼å…¥è®¡é‡å•位 */
function handleImport() {
  importModalApi.open();
}
/** å¯¼å‡ºè®¡é‡å•位(按当前列表查询条件导出全部命中数据) */
async function handleExport() {
  const data = await exportUnit(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '计量单位数据.xls', source: data });
}
/** ç¼–辑计量单位 */
@@ -87,6 +106,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="计量单位列表">
      <template #toolbar-tools>
        <TableAction
@@ -98,6 +118,20 @@
              auth: ['mdm:unit:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.import', ['计量单位']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mdm:unit-measure:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mdm:unit-measure:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
src/views/basicData/mdm/unit/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { h } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, notification, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importUnit, importUnitTemplate } from '#/api/mdm/unit';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    formItemClass: 'col-span-2',
    labelWidth: 120,
  },
  layout: 'horizontal',
  schema: useImportFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      const response = (await importUnit(data.file, data.updateSupport)) as any;
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
      const resultData = response?.data || response || {};
      const failureList = resultData.failureList || {};
      if (Object.keys(failureList).length > 0) {
        const errorNodes = Object.entries(failureList).map(([name, reason]) => {
          return h('div', `${name}: ${reason}`);
        });
        notification.warning({
          message: '导入完成,部分计量单位导入失败',
          description: () => h('div', errorNodes),
          duration: null,
        });
      } else {
        message.success($t('ui.actionMessage.operationSuccess'));
      }
    } finally {
      modalApi.unlock();
    }
  },
});
/** ä¸Šä¼ å‰ */
function beforeUpload(file: FileType) {
  formApi.setFieldValue('file', file);
  return false;
}
/** ä¸‹è½½æ¨¡ç‰ˆ */
async function handleDownload() {
  const data = await importUnitTemplate();
  downloadFileFromBlobPart({ fileName: '计量单位导入模板.xls', source: data });
}
</script>
<template>
  <Modal title="导入计量单位" class="w-1/3">
    <Form class="mx-4">
      <template #file>
        <div class="w-full">
          <Upload
            :max-count="1"
            accept=".xls,.xlsx"
            :before-upload="beforeUpload"
          >
            <Button type="primary"> é€‰æ‹© Excel æ–‡ä»¶ </Button>
          </Upload>
        </div>
      </template>
    </Form>
    <div class="mx-4 mt-2 mb-4 text-gray-500 text-xs">
      æç¤ºï¼šæ¨¡æ¿ä¸­çš„“主单位编码”请填写主单位的编码(如 PC),主单位本身该列留空即可。
      <div>“是否主单位”填 true/false。</div>
      <div>“状态”填 0(启用)/1(禁用)。</div>
    </div>
    <template #prepend-footer>
      <div class="flex flex-auto items-center">
        <Button @click="handleDownload"> ä¸‹è½½å¯¼å…¥æ¨¡æ¿ </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/bpm/businessAttach/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'processDefinitionKey',
      label: '流程定义 Key',
      component: 'Input',
      componentProps: {
        placeholder: '请输入流程定义 Key,如 purchase_approve',
      },
      rules: 'required',
    },
    {
      fieldName: 'businessType',
      label: '业务类型',
      component: 'Input',
      componentProps: {
        placeholder: '请输入业务类型,如 erp_purchase_order',
      },
    },
    {
      fieldName: 'componentPath',
      label: '业务详情组件路径',
      component: 'Input',
      componentProps: {
        placeholder:
          '请输入业务详情组件路径,如 /erp/purchase/order/detail/index.vue',
      },
      rules: 'required',
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: z.number().default(CommonStatusEnum.ENABLE),
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'processDefinitionKey',
      label: '流程定义 Key',
      component: 'Input',
      componentProps: {
        placeholder: '请输入流程定义 Key',
        allowClear: true,
      },
    },
    {
      fieldName: 'businessType',
      label: '业务类型',
      component: 'Input',
      componentProps: {
        placeholder: '请输入业务类型',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
    {
      fieldName: 'createTime',
      label: '创建时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'id',
      title: '编号',
      minWidth: 80,
    },
    {
      field: 'processDefinitionKey',
      title: '流程定义 Key',
      minWidth: 180,
    },
    {
      field: 'businessType',
      title: '业务类型',
      minWidth: 160,
    },
    {
      field: 'componentPath',
      title: '业务详情组件路径',
      minWidth: 260,
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 80,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 160,
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 160,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/bpm/businessAttach/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { BpmBusinessAttachApi } from '#/api/bpm/businessAttach';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteBusinessAttach,
  getBusinessAttachPage,
} from '#/api/bpm/businessAttach';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ›å»ºä¸šåŠ¡é™„ä»¶é…ç½® */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑业务附件配置 */
function handleEdit(row: BpmBusinessAttachApi.BusinessAttach) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤ä¸šåŠ¡é™„ä»¶é…ç½® */
async function handleDelete(row: BpmBusinessAttachApi.BusinessAttach) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.processDefinitionKey]),
    duration: 0,
  });
  try {
    await deleteBusinessAttach(row.id as number);
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getBusinessAttachPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<BpmBusinessAttachApi.BusinessAttach>,
});
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <Grid table-title="业务附件配置">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['业务附件配置']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['bpm:business-attach:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['bpm:business-attach:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['bpm:business-attach:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [
                  row.processDefinitionKey,
                ]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/bpm/businessAttach/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { BpmBusinessAttachApi } from '#/api/bpm/businessAttach';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createBusinessAttach,
  getBusinessAttach,
  updateBusinessAttach,
} from '#/api/bpm/businessAttach';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<BpmBusinessAttachApi.BusinessAttach>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['业务附件配置'])
    : $t('ui.actionTitle.create', ['业务附件配置']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-2',
    labelWidth: 140,
  },
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data = (await formApi.getValues()) as BpmBusinessAttachApi.BusinessAttach;
    try {
      await (formData.value?.id
        ? updateBusinessAttach(data)
        : createBusinessAttach(data));
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<BpmBusinessAttachApi.BusinessAttach>();
    if (!data || !data.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getBusinessAttach(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-2/3">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/bpm/model/data.ts
@@ -40,6 +40,12 @@
      slots: { default: 'deploymentTime' },
    },
    {
      field: 'autoApprove',
      title: '免审批',
      minWidth: 110,
      slots: { default: 'autoApprove' },
    },
    {
      title: '操作',
      width: 150,
      fixed: 'right',
src/views/bpm/model/index.vue
@@ -1,15 +1,37 @@
<script lang="ts" setup>
import type { ModelCategoryInfo } from '#/api/bpm/model';
import { onActivated, reactive, ref, useTemplateRef, watch } from 'vue';
import {
  computed,
  onActivated,
  onMounted,
  reactive,
  ref,
  useTemplateRef,
  watch,
} from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import { useSortable } from '@vueuse/integrations/useSortable';
import { Button, Card, Dropdown, Input, Menu, message } from 'ant-design-vue';
import {
  Button,
  Card,
  Dropdown,
  Input,
  Menu,
  message,
  Switch,
  Tooltip,
} from 'ant-design-vue';
import {
  getAutoApproveList,
  updateGlobalAutoApprove,
} from '#/api/bpm/autoApprove';
import {
  getCategorySimpleList,
  updateCategorySortBatch,
@@ -26,6 +48,17 @@
});
const modelListSpinning = ref(false); // æ¨¡åž‹åˆ—表加载状态
/** å…¨å±€å…å®¡æ‰¹æ€»å¼€å…³å ç”¨çš„æµç¨‹ Key(与后端 BpmProcessAutoApproveService.GLOBAL_KEY ä¿æŒä¸€è‡´ï¼‰ */
const GLOBAL_AUTO_APPROVE_KEY = '*';
const globalAutoApprove = ref(false); // å…¨å±€å…å®¡æ‰¹æ€»å¼€å…³
const globalAutoApproveLoading = ref(false); // æ€»å¼€å…³æäº¤çŠ¶æ€
const { hasAccessByCodes } = useAccess();
/** å…å®¡æ‰¹å¼€å…³çš„æƒé™ï¼šæ— æƒé™æ—¶å¼€å…³ç½®ç°ï¼Œé¿å…ç‚¹äº†æ‰æŠ¥é”™ */
const hasPermiAutoApprove = computed(() => {
  return hasAccessByCodes(['bpm:process-auto-approve:update']);
});
const saveSortLoading = ref(false); // ä¿å­˜æŽ’序状态
const categoryGroup = ref<ModelCategoryInfo[]>([]); // æŒ‰ç…§ category åˆ†ç»„的数据
@@ -59,8 +92,22 @@
async function getList() {
  modelListSpinning.value = true;
  try {
    const modelList = await getModelList(queryParams.name);
    const categoryList = await getCategorySimpleList();
    const [modelList, categoryList, autoApproveList] = await Promise.all([
      getModelList(queryParams.name),
      getCategorySimpleList(),
      getAutoApproveList(),
    ]);
    // å…å®¡æ‰¹é…ç½®æŒ‰ã€Œæµç¨‹ Key」建立映射,合并到模型行数据上,供列表内的免审批开关列直接读取
    const autoApproveMap = new Map<string, boolean>();
    autoApproveList.forEach((config) => {
      autoApproveMap.set(config.processDefinitionKey, config.autoApprove);
    });
    modelList.forEach((model: any) => {
      model.autoApprove = autoApproveMap.get(model.key) ?? false;
    });
    // å…¨å±€æ€»å¼€å…³ä»¥ç‰¹æ®Š Key å­˜æ”¾åœ¨åŒä¸€æ¡é…ç½®é‡Œ
    globalAutoApprove.value =
      autoApproveMap.get(GLOBAL_AUTO_APPROVE_KEY) ?? false;
    // æŒ‰ç…§ category èšåˆ
    categoryGroup.value = categoryList.map((category: any) => ({
      ...category,
@@ -76,6 +123,11 @@
}
/** åˆå§‹åŒ– */
onMounted(() => {
  getList();
});
/** æ¿€æ´»æ—¶åˆ·æ–°ï¼ˆkeep-alive ç¼“存场景下从编辑页返回时触发) */
onActivated(() => {
  getList();
});
@@ -137,6 +189,28 @@
    sortableInstance.value.option('disabled', true);
  }
}
/** åˆ‡æ¢å…¨å±€å…å®¡æ‰¹æ€»å¼€å…³ï¼šå¼€å¯åŽæ‰€æœ‰æµç¨‹ä¸€å¾‹å…å®¡æ‰¹ */
async function handleGlobalAutoApproveChange(checked: boolean) {
  globalAutoApprove.value = checked;
  globalAutoApproveLoading.value = true;
  try {
    await updateGlobalAutoApprove(checked);
    message.success(
      checked
        ? '已开启全局免审批:所有流程提交后将直接通过'
        : '已关闭全局免审批:恢复为按各流程开关判定',
    );
    // é‡æ–°æ‹‰å–列表,同步各流程开关的展示
    await getList();
  } catch (error) {
    // å¤±è´¥å›žæ»š
    globalAutoApprove.value = !checked;
    console.error('设置全局免审批开关失败', error);
  } finally {
    globalAutoApproveLoading.value = false;
  }
}
</script>
<template>
@@ -151,6 +225,16 @@
    >
      <template #extra>
        <div v-if="!isCategorySorting">
          <Tooltip title="开启后所有流程一律免审批(优先于各流程自己的开关)">
            <span class="mr-1">全部免审批</span>
          </Tooltip>
          <Switch
            :checked="globalAutoApprove"
            :loading="globalAutoApproveLoading"
            :disabled="!hasPermiAutoApprove"
            class="mr-3"
            @change="(checked: any) => handleGlobalAutoApproveChange(checked)"
          />
          <Input
            v-model:value="queryParams.name"
            placeholder="搜索流程"
src/views/bpm/model/modules/category-draggable-model.vue
@@ -21,11 +21,13 @@
  Dropdown,
  Menu,
  message,
  Switch,
  Tag,
  Tooltip,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { updateAutoApproveByKey } from '#/api/bpm/autoApprove';
import { deleteCategory } from '#/api/bpm/category';
import {
  cleanModel,
@@ -64,6 +66,7 @@
const isModelSorting = ref(false);
const originalData = ref<BpmModelApi.Model[]>([]);
const modelList = ref<BpmModelApi.Model[]>([]);
const autoApproveLoadingId = ref<null | number>(null); // å…å®¡æ‰¹å¼€å…³ï¼šæ­£åœ¨æäº¤çš„æ¨¡åž‹ç¼–号
// TODO @jason:可以全部展开么? @芋艿 ä¸Šæ¬¡è®¨è®ºã€‚好像是因为性能问题才只展开第一个分类
const isExpand = ref(props.isFirst); // æ ¹æ®æ˜¯å¦ä¸ºç¬¬ä¸€ä¸ªåˆ†ç±», æ¥è®¾ç½®åˆå§‹å±•开状态
@@ -81,6 +84,10 @@
});
const hasPermiDeploy = computed(() => {
  return hasAccessByCodes(['bpm:model:deploy']);
});
/** å…å®¡æ‰¹å¼€å…³çš„æƒé™ï¼šæ— æƒé™æ—¶å¼€å…³ç½®ç°ï¼Œé¿å…ç‚¹äº†æ‰æŠ¥é”™ */
const hasPermiAutoApprove = computed(() => {
  return hasAccessByCodes(['bpm:process-auto-approve:update']);
});
const [Grid, gridApi] = useVbenVxeGrid({
@@ -287,6 +294,32 @@
  message.success(`发布[${row.name}]流程成功`);
  // åˆ·æ–°åˆ—表
  emit('success');
}
/** åˆ‡æ¢å…å®¡æ‰¹å¼€å…³ï¼šå¼€å¯åŽè¯¥æµç¨‹æäº¤æ—¶ä¸å†è¿›å…¥äººå·¥å®¡æ‰¹ï¼Œç”±ç³»ç»Ÿè‡ªåŠ¨é€šè¿‡ */
async function handleAutoApproveChange(checked: boolean, row: any) {
  const oldValue = row.autoApprove;
  // ä¹è§‚更新,避免等待接口期间开关状态回跳
  row.autoApprove = checked;
  gridApi.setGridOptions({ data: modelList.value });
  autoApproveLoadingId.value = row.id;
  try {
    await updateAutoApproveByKey(row.key, checked);
    message.success(
      checked
        ? `已开启[${row.name}]免审批:该流程提交后将由系统自动通过`
        : `已关闭[${row.name}]免审批:恢复为正常人工审批`,
    );
    // é‡æ–°æ‹‰å–列表,保证前端状态与后端一致
    emit('success');
  } catch (error) {
    // å¤±è´¥å›žæ»š
    row.autoApprove = oldValue;
    gridApi.setGridOptions({ data: modelList.value });
    console.error('设置免审批开关失败', error);
  } finally {
    autoApproveLoadingId.value = null;
  }
}
/** '更多'操作按钮 */
@@ -661,6 +694,28 @@
                </Tag>
              </div>
            </template>
            <template #autoApprove="{ row }">
              <div class="flex items-center gap-1">
                <Switch
                  :checked="row.autoApprove"
                  :loading="autoApproveLoadingId === row.id"
                  :disabled="!hasPermiAutoApprove"
                  size="small"
                  @change="
                    (checked: any) => handleAutoApproveChange(checked, row)
                  "
                />
                <Tooltip
                  v-if="!row.processDefinition"
                  title="流程尚未发布,免审批开关不会生效,请先发布流程"
                >
                  <IconifyIcon
                    icon="lucide:alert-circle"
                    class="text-base text-orange-400"
                  />
                </Tooltip>
              </div>
            </template>
            <template #actions="{ row }">
              <div class="flex items-center space-x-0">
                <Button
src/views/bpm/processInstance/detail/index.vue
@@ -4,6 +4,8 @@
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import {
  BpmFieldPermissionType,
@@ -41,11 +43,16 @@
defineOptions({ name: 'BpmProcessInstanceDetail' });
const route = useRoute();
const props = defineProps<{
  activityId?: string; // æµç¨‹æ´»åŠ¨ç¼–å·ï¼Œç”¨äºŽæŠ„é€æŸ¥çœ‹
  id: string; // æµç¨‹å®žä¾‹çš„编号
  taskId?: string; // ä»»åŠ¡ç¼–å·
}>();
/** åªè¯»æŸ¥çœ‹æ¨¡å¼ï¼šä»Žä¸šåŠ¡æ¨¡å—ï¼ˆå¦‚é”€å”®è®¢å•å®¡æ‰¹è¿›åº¦ï¼‰è·³è½¬æ—¶éšè—å®¡æ‰¹æ“ä½œæŒ‰é’®ï¼Œä»…æŸ¥çœ‹ */
const viewOnly = route.query.viewOnly === '1';
const processInstanceLoading = ref(false); // æµç¨‹å®žä¾‹çš„加载中
const processInstance = ref<BpmProcessInstanceApi.ProcessInstance>(); // æµç¨‹å®žä¾‹
@@ -362,7 +369,7 @@
      </div>
      <template #actions>
        <div class="px-4">
        <div v-if="!viewOnly" class="px-4">
          <ProcessInstanceOperationButton
            ref="operationButtonRef"
            :process-instance="processInstance"
@@ -374,6 +381,9 @@
            @success="refresh"
          />
        </div>
        <div v-else class="px-4 py-2 text-sm text-muted-foreground">
          <span>只读查看模式:审批操作请在「协同办公 â†’ å¾…办任务」中处理</span>
        </div>
      </template>
    </Card>
    <!-- æ‰“印对话框 -->
src/views/bpm/task/done/data.ts
@@ -146,7 +146,7 @@
    },
    {
      title: '操作',
      width: 120,
      width: 180,
      fixed: 'right',
      slots: { default: 'actions' },
    },
src/views/bpm/task/done/index.vue
@@ -7,6 +7,7 @@
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { reverseApprove } from '#/api/erp/purchase/order';
import { getTaskDonePage, withdrawTask } from '#/api/bpm/task';
import { router } from '#/router';
@@ -34,6 +35,28 @@
  try {
    await withdrawTask(row.id);
    message.success('撤回成功');
    await gridApi.query();
  } finally {
    hideLoading();
  }
}
/** æ˜¯å¦é‡‡è´­è®¢å•审批任务(用于显示"反审批"、隐藏通用"撤回") */
function isPurchaseOrderApprove(row: BpmTaskApi.Task) {
  return (row.processInstance?.processDefinitionId || '').startsWith(
    'purchase_order_approve',
  );
}
/** åå®¡æ‰¹é‡‡è´­è®¢å•(已审核 -> æœªå®¡æ ¸ï¼Œåœ¨ååŒåŠžå…¬å·²åŠžä»»åŠ¡ä¸­æ“ä½œï¼‰ */
async function handleReverseApprove(row: BpmTaskApi.Task) {
  const hideLoading = message.loading({
    content: '反审批中...',
    duration: 0,
  });
  try {
    await reverseApprove(row.processInstance.id);
    message.success('反审批成功,订单已重新进入待办审批');
    await gridApi.query();
  } finally {
    hideLoading();
@@ -77,10 +100,22 @@
        <TableAction
          :actions="[
            {
              label: '反审批',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.AUDIT,
              ifShow: () => isPurchaseOrderApprove(row),
              popConfirm: {
                title: '确定要反审批该采购订单吗?订单将重新进入待办审批',
                confirm: handleReverseApprove.bind(null, row),
              },
            },
            {
              label: '撤回',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              ifShow: () => !isPurchaseOrderApprove(row),
              popConfirm: {
                title: '确定要撤回该任务吗?',
                confirm: handleWithdraw.bind(null, row),
src/views/crm/backlog/modules/receivable-audit-list.vue
@@ -5,22 +5,29 @@
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage } from '#/api/crm/receivable';
import { getReceivablePage, updateReceivableStatus } from '#/api/crm/receivable';
import { useGridColumns } from '#/views/crm/receivable/data';
import { AUDIT_STATUS } from '../data';
const { push } = useRouter();
/** æŸ¥çœ‹å®¡æ‰¹ */
function handleProcessDetail(row: CrmReceivableApi.Receivable) {
  push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
/** å®¡æ ¸ */
async function handleAudit(row: CrmReceivableApi.Receivable) {
  const hideLoading = message.loading({
    content: '审核中...',
    duration: 0,
  });
  try {
    await updateReceivableStatus(row.id!, 20);
    message.success('审核成功');
    gridApi.query();
  } finally {
    hideLoading();
  }
}
/** æ‰“开回款详情 */
@@ -38,7 +45,7 @@
  push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
const [Grid] = useVbenVxeGrid({
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
@@ -98,7 +105,13 @@
      </Button>
    </template>
    <template #actions="{ row }">
      <Button type="link" @click="handleProcessDetail(row)">查看审批</Button>
      <Button
        v-if="row.auditStatus === 10"
        type="link"
        @click="handleAudit(row)"
      >
        å®¡æ ¸
      </Button>
    </template>
  </Grid>
</template>
src/views/crm/business/detail/index.vue
@@ -87,7 +87,9 @@
/** è½¬ç§»å•†æœº */
function handleTransfer() {
  transferModalApi.setData({ bizType: BizTypeEnum.CRM_BUSINESS }).open();
  transferModalApi
    .setData({ id: businessId.value, bizType: BizTypeEnum.CRM_BUSINESS })
    .open();
}
/** æ›´æ–°å•†æœºçŠ¶æ€æ“ä½œ */
src/views/crm/clue/detail/index.vue
@@ -78,7 +78,9 @@
/** è½¬ç§»çº¿ç´¢ */
function handleTransfer() {
  transferModalApi.setData({ bizType: BizTypeEnum.CRM_CLUE }).open();
  transferModalApi
    .setData({ id: clueId.value, bizType: BizTypeEnum.CRM_CLUE })
    .open();
}
/** è½¬åŒ–为客户 */
src/views/crm/contact/detail/index.vue
@@ -80,7 +80,9 @@
/** è½¬ç§»è”系人 */
function handleTransfer() {
  transferModalApi.setData({ id: contactId.value }).open();
  transferModalApi
    .setData({ id: contactId.value, bizType: BizTypeEnum.CRM_CONTACT })
    .open();
}
/** åŠ è½½æ•°æ® */
src/views/crm/contract/components/data.ts
@@ -53,6 +53,18 @@
      },
    },
    {
      title: '已开票金额(元)',
      field: 'totalInvoicePrice',
      minWidth: 150,
      formatter: 'formatAmount2',
    },
    {
      title: '未开票金额(元)',
      field: 'unInvoicePrice',
      minWidth: 150,
      formatter: 'formatAmount2',
    },
    {
      title: '负责人',
      field: 'ownerUserName',
      minWidth: 150,
src/views/crm/contract/data.ts
@@ -415,6 +415,18 @@
      },
    },
    {
      title: '已开票金额(元)',
      field: 'totalInvoicePrice',
      minWidth: 150,
      formatter: 'formatAmount2',
    },
    {
      title: '未开票金额(元)',
      field: 'unInvoicePrice',
      minWidth: 140,
      formatter: 'formatAmount2',
    },
    {
      title: '最后跟进时间',
      field: 'contactLastTime',
      minWidth: 180,
src/views/crm/contract/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,190 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import type { DescriptionItemSchema } from '#/components/description';
import { h, nextTick, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
} from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContract } from '#/api/crm/contract';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'CrmContractApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<CrmContractApi.Contract>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** åˆåŒäº§å“æ˜Žç»†ï¼ˆå®¡æ‰¹è¯¦æƒ…只读列) */
function useProductColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', width: 60 },
    { field: 'itemName', title: '产品名称', minWidth: 180 },
    { field: 'itemCode', title: '物料编码', minWidth: 120 },
    { field: 'itemUnitName', title: '单位', minWidth: 80, formatter: ({ cellValue }) => textRender(cellValue) },
    {
      field: 'contractPrice',
      title: '合同价格(元)',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'count',
      title: '数量',
      minWidth: 100,
      formatter: ({ cellValue }) => erpCountInputFormatter(cellValue) || '-',
    },
    {
      field: 'totalPrice',
      title: '合计金额(元)',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
  ];
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useProductColumns(),
    data: [],
    minHeight: 200,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  },
});
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '合同编号', field: 'no', render: textRender },
    { label: '合同名称', field: 'name', render: textRender },
    { label: '客户名称', field: 'customerName', render: textRender },
    { label: '商机名称', field: 'businessName', render: textRender },
    {
      label: '合同金额(元)',
      field: 'totalPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '定金金额(元)',
      field: 'depositPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '回款金额(元)',
      field: 'totalReceivablePrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '已开票金额(元)',
      field: 'totalInvoicePrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '未开票金额(元)',
      field: 'unInvoicePrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '下单时间',
      field: 'orderDate',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '合同开始时间',
      field: 'startTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '合同结束时间',
      field: 'endTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '客户签约人', field: 'signContactName', render: textRender },
    { label: '公司签约人', field: 'signUserName', render: textRender },
    { label: '销售订单', field: 'orderNo', render: textRender },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '合同状态',
      field: 'auditStatus',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.CRM_AUDIT_STATUS, value: val }),
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getContract(Number(props.id));
    if (formData.value?.products?.length) {
      await nextTick();
      await gridApi.grid.reloadData(formData.value.products);
    }
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- åˆåŒåŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">合同基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- äº§å“æ˜Žç»† -->
        <div
          v-if="formData?.products?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">产品明细</span>
          </div>
          <Grid />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/crm/contract/detail/data.ts
@@ -35,6 +35,11 @@
      render: (val) => erpPriceInputFormatter(val) as string,
    },
    {
      field: 'totalInvoicePrice',
      label: '已开票金额(含审批中)',
      render: (val) => erpPriceInputFormatter(val) as string,
    },
    {
      field: 'ownerUserName',
      label: '负责人',
    },
@@ -66,6 +71,16 @@
      render: (val) => erpPriceInputFormatter(val) as string,
    },
    {
      field: 'totalInvoicePrice',
      label: '已开票金额(含审批中)',
      render: (val) => erpPriceInputFormatter(val) as string,
    },
    {
      field: 'unInvoicePrice',
      label: '未开票金额(元)',
      render: (val) => erpPriceInputFormatter(val) as string,
    },
    {
      field: 'depositPrice',
      label: '定金金额(元)',
      render: (val) => erpPriceInputFormatter(val) as string,
src/views/crm/contract/detail/index.vue
@@ -85,7 +85,9 @@
/** è½¬ç§»åˆåŒ */
function handleTransfer() {
  transferModalApi.setData({ bizType: BizTypeEnum.CRM_CONTRACT }).open();
  transferModalApi
    .setData({ id: contractId.value, bizType: BizTypeEnum.CRM_CONTRACT })
    .open();
}
/** åŠ è½½æ•°æ® */
src/views/crm/contract/index.vue
@@ -23,12 +23,18 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -51,6 +57,11 @@
    ...formValues,
  });
  downloadFileFromBlobPart({ fileName: '合同.xls', source: data });
}
/** å¯¼å…¥åˆåŒ */
function handleImport() {
  importModalApi.open();
}
/** åˆ›å»ºåˆåŒ */
@@ -205,6 +216,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-actions>
        <Tabs class="w-full" @change="handleChangeSceneType">
@@ -230,6 +242,13 @@
              auth: ['crm:contract:export'],
              onClick: handleExport,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['crm:contract:import'],
              onClick: handleImport,
            },
          ]"
        />
      </template>
src/views/crm/contract/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,155 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { ImportResult } from '#/api/crm/contract';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Upload } from 'ant-design-vue';
import { importContract, importContractTemplate } from '#/api/crm/contract';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const importResult = ref<ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importContract(fileList.value[0] as File);
      // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
      if (failedCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importContractTemplate();
  downloadFileFromBlobPart({ fileName: '合同导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createList?.length ?? 0);
const updateCount = computed(() => importResult.value?.updateList?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureList ?? {}).length,
);
</script>
<template>
  <Modal title="导入合同" class="w-[720px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:合同名称、客户名称、产品编码、数量。“客户名称”需与 CRM å®¢æˆ·ä¸€è‡´ï¼›â€œäº§å“ç¼–码”按物料编码填写(“产品名称”列仅供对照);“合同单价(元)”留空则取物料销售价。</p>
        <p>一张合同可含多条明细:相同“合同编号”的多行会合并为一张合同(主表字段取该组首行);“合同编号”留空时每一行各生成一张新合同。合同编号已存在时会导入失败。</p>
        <p>合同金额按“明细单价×数量 âˆ’ ä¼˜æƒ çŽ‡â€è‡ªåŠ¨è®¡ç®—ï¼›å®šé‡‘é‡‘é¢å¯å¡«ï¼›è´Ÿè´£äºº = å½“前登录用户;状态为“未提交”,可在列表中继续编辑或提交审批。</p>
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">正在导入合同数据,请稍候...</div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} æ¡</span>
          <span class="text-blue-600">覆盖更新:{{ updateCount }} æ¡</span>
          <span class="text-red-600">失败:{{ failureCount }} æ¡</span>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureList"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/crm/customer/detail/index.vue
@@ -94,9 +94,11 @@
  formModalApi.setData({ id: customerId.value }).open();
}
/** è½¬ç§»çº¿ç´¢ */
/** è½¬ç§»å®¢æˆ· */
function handleTransfer() {
  transferModalApi.setData({ id: customerId.value }).open();
  transferModalApi
    .setData({ id: customerId.value, bizType: BizTypeEnum.CRM_CUSTOMER })
    .open();
}
/** é”å®šå®¢æˆ· */
src/views/crm/invoice/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,315 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { getContractSimpleList } from '#/api/crm/contract';
import { getSimpleUserList } from '#/api/system/user';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { markRaw } from 'vue';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  const userStore = useUserStore();
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '开票编号',
      component: 'Input',
      componentProps: {
        placeholder: '保存时自动生成',
        disabled: true,
      },
    },
    {
      fieldName: 'ownerUserId',
      label: '负责人',
      component: 'ApiSelect',
      rules: 'required',
      dependencies: {
        triggerFields: ['id'],
        disabled: (values) => !!values.id,
      },
      componentProps: {
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
        placeholder: '请选择负责人',
        allowClear: true,
      },
      defaultValue: userStore.userInfo?.id,
    },
    {
      fieldName: 'customerId',
      label: '客户名称',
      component: markRaw(CrmCustomerSelect),
      rules: 'required',
      componentProps: (_values, form) => ({
        placeholder: '请选择客户',
        onChange: () => {
          form.setFieldValue('contractId', undefined);
          form.setFieldValue('price', undefined);
        },
      }),
      dependencies: {
        triggerFields: ['id'],
        disabled: (values) => !!values.id,
      },
    },
    {
      fieldName: 'contractId',
      label: '合同名称',
      component: 'Select',
      rules: 'required',
      dependencies: {
        triggerFields: ['customerId'],
        disabled: (values) => !values.customerId || !!values.id,
        async componentProps(values, form) {
          if (!values.customerId) {
            return {
              options: [],
              placeholder: '请先选择客户',
            };
          }
          const contracts = await getContractSimpleList(values.customerId);
          const approvedContracts = contracts.filter(
            (item) => item.auditStatus === 20,
          );
          return {
            options: approvedContracts.map((item) => {
              const remain =
                (item.totalPrice ?? 0) - (item.totalInvoicePrice ?? 0);
              return {
                label: `${item.name}(¥${item.totalPrice ?? 0},可开票 Â¥${remain.toFixed(2)})`,
                value: item.id,
              };
            }),
            placeholder: '请选择合同',
            onChange: () => {
              form.setFieldValue('price', undefined);
            },
          } as any;
        },
      },
    },
    {
      fieldName: 'invoiceType',
      label: '发票类型',
      component: 'Select',
      rules: 'required',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_INVOICE_TYPE, 'number'),
        placeholder: '请选择发票类型',
      },
    },
    {
      fieldName: 'invoiceNo',
      label: '发票号码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入发票号码',
        allowClear: true,
      },
    },
    {
      fieldName: 'invoiceTitle',
      label: '发票抬头',
      component: 'Input',
      componentProps: {
        placeholder: '请输入发票抬头',
        allowClear: true,
      },
    },
    {
      fieldName: 'price',
      label: '开票金额(元)',
      component: 'InputNumber',
      rules: 'required',
      componentProps: {
        class: '!w-full',
        placeholder: '请输入开票金额',
        min: 0,
        precision: 2,
      },
    },
    {
      fieldName: 'invoiceTime',
      label: '开票日期',
      component: 'DatePicker',
      rules: 'required',
      componentProps: {
        placeholder: '请选择开票日期',
        showTime: false,
        valueFormat: 'YYYY-MM-DD',
        format: 'YYYY-MM-DD',
      },
    },
    {
      fieldName: 'blobIds',
      label: '发票附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        accept: ['jpg', 'jpeg', 'png', 'pdf'],
        helpText: '支持jpg、jpeg、png、pdf格式,单个文件不超过50MB',
      },
      formItemClass: 'md:col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 4,
      },
      formItemClass: 'md:col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '开票编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入开票编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'customerId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        placeholder: '请选择客户',
        allowClear: true,
      },
    },
  ];
}
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      title: '开票编号',
      field: 'no',
      minWidth: 160,
      fixed: 'left',
      slots: { default: 'no' },
    },
    {
      title: '客户名称',
      field: 'customerName',
      minWidth: 150,
      slots: { default: 'customerName' },
    },
    {
      title: '合同编号',
      field: 'contract',
      minWidth: 160,
      slots: { default: 'contractNo' },
    },
    {
      title: '发票类型',
      field: 'invoiceType',
      minWidth: 120,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_INVOICE_TYPE },
      },
    },
    {
      title: '发票号码',
      field: 'invoiceNo',
      minWidth: 150,
    },
    {
      title: '开票金额(元)',
      field: 'price',
      minWidth: 150,
      formatter: 'formatAmount2',
    },
    {
      title: '剩余可回款金额(含审批中)',
      field: 'remainingInvoicePrice',
      minWidth: 180,
      formatter: 'formatAmount2',
    },
    {
      title: '开票日期',
      field: 'invoiceTime',
      minWidth: 150,
      formatter: 'formatDateTime',
    },
    {
      title: '备注',
      field: 'remark',
      minWidth: 150,
    },
    {
      title: '负责人',
      field: 'ownerUserName',
      minWidth: 150,
    },
    {
      title: '所属部门',
      field: 'ownerUserDeptName',
      minWidth: 150,
    },
    {
      title: '更新时间',
      field: 'updateTime',
      minWidth: 150,
      formatter: 'formatDateTime',
    },
    {
      title: '创建时间',
      field: 'createTime',
      minWidth: 150,
      formatter: 'formatDateTime',
    },
    {
      title: '创建人',
      field: 'creatorName',
      minWidth: 150,
    },
    {
      title: '附件',
      field: 'attachmentList',
      minWidth: 100,
      fixed: 'right',
      slots: { default: 'attachment' },
    },
    {
      title: '审批状态',
      field: 'auditStatus',
      minWidth: 100,
      fixed: 'right',
      slots: { default: 'auditStatus' },
    },
    {
      title: '操作',
      field: 'actions',
      minWidth: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/crm/invoice/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { getInvoice } from '#/api/crm/invoice';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'CrmInvoiceApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<CrmInvoiceApi.Invoice>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '开票编号', field: 'no', render: textRender },
    { label: '客户名称', field: 'customerName', render: textRender },
    {
      label: '合同编号',
      field: 'contract',
      render: (_val, data) => (data?.contract?.no ? data.contract.no : '-'),
    },
    {
      label: '发票类型',
      field: 'invoiceType',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.CRM_INVOICE_TYPE, value: val }),
    },
    { label: '发票号码', field: 'invoiceNo', render: textRender },
    { label: '发票抬头', field: 'invoiceTitle', render: textRender },
    {
      label: '开票金额(元)',
      field: 'price',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '剩余可回款金额(元)',
      field: 'remainingInvoicePrice',
      render: (val) => erpPriceInputFormatter(val ?? 0) || '-',
    },
    {
      label: '开票日期',
      field: 'invoiceTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '审批状态',
      field: 'auditStatus',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.CRM_AUDIT_STATUS, value: val }),
    },
    { label: '负责人', field: 'ownerUserName', render: textRender },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getInvoice(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- å¼€ç¥¨åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">开票基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/crm/invoice/detail/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,134 @@
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
/** è¯¦æƒ…页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
  return [
    {
      field: 'customerName',
      label: '客户名称',
    },
    {
      field: 'price',
      label: '开票金额(元)',
      render: (val) => erpPriceInputFormatter(val),
    },
    {
      field: 'remainingInvoicePrice',
      label: '剩余可回款金额(含审批中)',
      render: (val) => erpPriceInputFormatter(val ?? 0),
    },
    {
      field: 'invoiceTime',
      label: '开票日期',
      render: (val) => formatDateTime(val) as string,
    },
    {
      field: 'invoiceType',
      label: '发票类型',
      render: (val) =>
        h(DictTag, {
          type: DICT_TYPE.CRM_INVOICE_TYPE,
          value: val,
        }),
    },
    {
      field: 'invoiceTitle',
      label: '发票抬头',
    },
    {
      field: 'invoiceNo',
      label: '发票号码',
    },
    {
      field: 'ownerUserName',
      label: '负责人',
    },
  ];
}
/** è¯¦æƒ…页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
  return [
    {
      field: 'no',
      label: '开票编号',
    },
    {
      field: 'customerName',
      label: '客户名称',
    },
    {
      field: 'contract',
      label: '合同编号',
      render: (val, data) =>
        val && data?.contract?.no ? data?.contract?.no : '',
    },
    {
      field: 'invoiceType',
      label: '发票类型',
      render: (val) =>
        h(DictTag, {
          type: DICT_TYPE.CRM_INVOICE_TYPE,
          value: val,
        }),
    },
    {
      field: 'invoiceNo',
      label: '发票号码',
    },
    {
      field: 'invoiceTitle',
      label: '发票抬头',
    },
    {
      field: 'price',
      label: '开票金额',
      render: (val) => erpPriceInputFormatter(val),
    },
    {
      field: 'remainingInvoicePrice',
      label: '剩余可回款金额(含审批中)',
      render: (val) => erpPriceInputFormatter(val ?? 0),
    },
    {
      field: 'invoiceTime',
      label: '开票日期',
      render: (val) => formatDateTime(val) as string,
    },
    {
      field: 'remark',
      label: '备注',
    },
  ];
}
/** ç³»ç»Ÿä¿¡æ¯å­—段 */
export function useDetailSystemSchema(): DescriptionItemSchema[] {
  return [
    {
      field: 'ownerUserName',
      label: '负责人',
    },
    {
      field: 'creatorName',
      label: '创建人',
    },
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
    },
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) as string,
    },
  ];
}
src/views/crm/invoice/detail/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,128 @@
<script setup lang="ts">
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { Card, Tabs } from 'ant-design-vue';
import { getInvoice } from '#/api/crm/invoice';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { PermissionList } from '#/views/crm/permission';
import InvoiceForm from '../modules/form.vue';
import { useDetailSchema } from './data';
import Info from './modules/info.vue';
const props = defineProps<{ id?: number }>();
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const loading = ref(false);
const invoiceId = ref(0);
const invoice = ref<CrmInvoiceApi.Invoice>({} as CrmInvoiceApi.Invoice);
const logList = ref<SystemOperateLogApi.OperateLog[]>([]);
const permissionListRef = ref<InstanceType<typeof PermissionList>>();
const [Descriptions] = useDescription({
  bordered: false,
  column: 4,
  schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: InvoiceForm,
  destroyOnClose: true,
});
/** åŠ è½½å¼€ç¥¨è¯¦æƒ… */
async function loadInvoiceDetail() {
  loading.value = true;
  try {
    invoice.value = await getInvoice(invoiceId.value);
    const res = await getOperateLogPage({
      bizType: BizTypeEnum.CRM_INVOICE,
      bizId: invoiceId.value,
    });
    logList.value = res.list;
  } finally {
    loading.value = false;
  }
}
/** è¿”回列表页 */
function handleBack() {
  tabs.closeCurrentTab();
  router.push({ name: 'CrmInvoice' });
}
/** ç¼–辑开票 */
function handleEdit() {
  formModalApi.setData({ invoice: { id: invoiceId.value } }).open();
}
/** åŠ è½½æ•°æ® */
onMounted(() => {
  invoiceId.value = Number(props.id || route.params.id);
  loadInvoiceDetail();
});
</script>
<template>
  <Page auto-content-height :title="invoice?.no" :loading="loading">
    <FormModal @success="loadInvoiceDetail" />
    <template #extra>
      <TableAction
        :actions="[
          {
            label: '返回',
            type: 'default',
            icon: 'lucide:arrow-left',
            onClick: handleBack,
          },
          {
            label: $t('ui.actionTitle.edit'),
            type: 'primary',
            icon: ACTION_ICON.EDIT,
            auth: ['crm:invoice:update'],
            ifShow: permissionListRef?.validateWrite,
            onClick: handleEdit,
          },
        ]"
      />
    </template>
    <Card class="min-h-[10%]">
      <Descriptions :data="invoice" />
    </Card>
    <Card class="mt-4 min-h-[60%]">
      <Tabs>
        <Tabs.TabPane tab="详细资料" key="1" :force-render="true">
          <Info :invoice="invoice" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="2" :force-render="true">
          <OperateLog :log-list="logList" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="团队成员" key="3" :force-render="true">
          <PermissionList
            ref="permissionListRef"
            :biz-id="invoiceId"
            :biz-type="BizTypeEnum.CRM_INVOICE"
            :show-action="true"
            @quit-team="handleBack"
          />
        </Tabs.TabPane>
      </Tabs>
    </Card>
  </Page>
</template>
src/views/crm/invoice/detail/modules/info.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
<script lang="ts" setup>
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import { Divider } from 'ant-design-vue';
import { useDescription } from '#/components/description';
import { useDetailBaseSchema, useDetailSystemSchema } from '../data';
const props = defineProps<{
  invoice: CrmInvoiceApi.Invoice;
}>();
const [BaseDescriptions] = useDescription({
  title: '基本信息',
  bordered: false,
  column: 4,
  class: 'mx-4',
  schema: useDetailBaseSchema(),
});
const [SystemDescriptions] = useDescription({
  title: '系统信息',
  bordered: false,
  column: 3,
  class: 'mx-4',
  schema: useDetailSystemSchema(),
});
</script>
<template>
  <div>
    <BaseDescriptions :data="invoice" />
    <Divider />
    <div v-if="props.invoice?.attachmentList?.length" class="mx-4 mb-4">
      <h4 class="mb-2 text-base font-medium">发票附件</h4>
      <div
        v-for="file in props.invoice.attachmentList"
        :key="file.id"
        class="flex items-center gap-2 py-1"
      >
        <a :href="file.url" target="_blank" rel="noopener noreferrer">
          {{ file.name || '附件' }}
        </a>
      </div>
    </div>
    <SystemDescriptions :data="invoice" />
  </div>
</template>
src/views/crm/invoice/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,259 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tag, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteInvoice,
  exportInvoice,
  getInvoicePage,
  submitInvoice,
} from '#/api/crm/invoice';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¤„理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
  sceneType.value = key.toString();
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const formValues = await gridApi.formApi.getValues();
  const data = await exportInvoice({
    sceneType: sceneType.value,
    ...formValues,
  });
  downloadFileFromBlobPart({ fileName: '开票.xls', source: data });
}
/** åˆ›å»ºå¼€ç¥¨ */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑开票 */
function handleEdit(row: CrmInvoiceApi.Invoice) {
  formModalApi.setData({ invoice: row }).open();
}
/** åˆ é™¤å¼€ç¥¨ */
async function handleDelete(row: CrmInvoiceApi.Invoice) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.no]),
    duration: 0,
  });
  try {
    await deleteInvoice(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æäº¤å®¡æ ¸ */
async function handleSubmit(row: CrmInvoiceApi.Invoice) {
  const hideLoading = message.loading({
    content: '提交审核中...',
    duration: 0,
  });
  try {
    await submitInvoice(row.id!);
    message.success('提交审核成功');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹å¼€ç¥¨è¯¦æƒ… */
function handleDetail(row: CrmInvoiceApi.Invoice) {
  push({ name: 'CrmInvoiceDetail', params: { id: row.id } });
}
/** æŸ¥çœ‹å®¢æˆ·è¯¦æƒ… */
function handleCustomerDetail(row: CrmInvoiceApi.Invoice) {
  push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** æŸ¥çœ‹åˆåŒè¯¦æƒ… */
function handleContractDetail(row: CrmInvoiceApi.Invoice) {
  push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: CrmInvoiceApi.Invoice) {
  push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getInvoicePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            sceneType: sceneType.value,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<CrmInvoiceApi.Invoice>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-actions>
        <Tabs class="w-full" @change="handleChangeSceneType">
          <Tabs.TabPane tab="我负责的" key="1" />
          <Tabs.TabPane tab="我参与的" key="2" />
          <Tabs.TabPane tab="下属负责的" key="3" />
        </Tabs>
      </template>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增开票申请',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['crm:invoice:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['crm:invoice:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #no="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.no }}
        </Button>
      </template>
      <template #customerName="{ row }">
        <Button type="link" @click="handleCustomerDetail(row)">
          {{ row.customerName }}
        </Button>
      </template>
      <template #contractNo="{ row }">
        <Button
          v-if="row.contract"
          type="link"
          @click="handleContractDetail(row)"
        >
          {{ row.contract.no }}
        </Button>
        <span v-else>--</span>
      </template>
      <template #attachment="{ row }">
        <Tag v-if="!row.hasAttachment" color="warning">
          æœªä¸Šä¼ 
        </Tag>
        <Tag
          v-else
          color="success"
          style="cursor: pointer"
          @click="handleDetail(row)"
        >
          å·²ä¸Šä¼ 
        </Tag>
      </template>
      <template #auditStatus="{ row }">
        <Tag v-if="row.auditStatus === 0" color="default">未提交</Tag>
        <Tag v-if="row.auditStatus === 10" color="warning">审批中</Tag>
        <Tag v-if="row.auditStatus === 20" color="success">审核通过</Tag>
        <Tag v-if="row.auditStatus === 30" color="error">审核不通过</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['crm:invoice:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审核',
              type: 'link',
              auth: ['crm:invoice:update'],
              onClick: handleSubmit.bind(null, row),
              ifShow: row.auditStatus === 0,
            },
            {
              label: '查看审批',
              type: 'link',
              auth: ['crm:invoice:update'],
              onClick: handleProcessDetail.bind(null, row),
              ifShow: row.auditStatus !== 0 && !!row.processInstanceId,
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['crm:invoice:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/crm/invoice/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,128 @@
<script lang="ts" setup>
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { getContractSimpleList } from '#/api/crm/contract';
import {
  createInvoice,
  getInvoice,
  updateInvoice,
} from '#/api/crm/invoice';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
type InvoicePrefillData = Partial<
  Pick<CrmInvoiceApi.Invoice, 'contractId' | 'customerId'>
>;
type InvoiceFormModalData = InvoicePrefillData & {
  invoice?: Pick<CrmInvoiceApi.Invoice, 'id'>;
};
const emit = defineEmits(['success']);
const formData = ref<Partial<CrmInvoiceApi.Invoice>>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['开票'])
    : $t('ui.actionTitle.create', ['开票']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
/** æž„建新增开票的预填表单 */
async function buildCreateFormData(
  prefill: InvoicePrefillData,
): Promise<Partial<CrmInvoiceApi.Invoice>> {
  const values: Partial<CrmInvoiceApi.Invoice> = {
    contractId: prefill?.contractId,
    customerId: prefill?.customerId,
  };
  if (values.customerId && values.contractId) {
    const contracts = await getContractSimpleList(values.customerId);
    const contract = contracts.find((item) => item.id === values.contractId);
    if (contract) {
      values.invoiceTitle = contract.customerName;
    }
  }
  return values;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as CrmInvoiceApi.Invoice;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formData.value?.id
        ? updateInvoice(data)
        : createInvoice(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    formData.value = undefined;
    await formApi.resetForm();
    const data = modalApi.getData() as null | InvoiceFormModalData;
    if (!data) {
      return;
    }
    const { invoice } = data;
    const prefill = data.customerId || data.contractId ? data : undefined;
    modalApi.lock();
    try {
      if (invoice?.id) {
        formData.value = await getInvoice(invoice.id!);
        // ç”¨ attachmentList ä»£æ›¿ blobIds,让上传组件能展示文件名和预览链接
        if (formData.value?.attachmentList?.length) {
          (formData.value as Record<string, unknown>).blobIds =
            formData.value.attachmentList;
        }
      } else if (prefill) {
        formData.value = await buildCreateFormData(prefill);
      }
      if (formData.value) {
        await formApi.setValues(formData.value as any);
      }
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/crm/permission/modules/list.vue
@@ -57,6 +57,7 @@
}) {
  if (records.some((item) => item.level === PermissionLevelEnum.OWNER)) {
    message.warning('不能选择负责人!');
    checkedRows.value = [];
    gridApi.grid.setAllCheckboxRow(false);
    return;
  }
@@ -114,17 +115,23 @@
  }
  // æç¤ºå¹¶è¿”回成功
  message.success($t('ui.actionMessage.operationSuccess'));
  checkedRows.value = [];
  gridApi.grid.setAllCheckboxRow(false);
  handleRefresh();
  return true;
}
/** é€€å‡ºå›¢é˜Ÿ */
async function handleQuit() {
  if (checkedRows.value.length > 0) {
    return handleDelete();
  }
  const permission = gridApi.grid
    .getData()
    .find(
      (item) =>
        item.id === userStore.userInfo?.id &&
        item.userId === userStore.userInfo?.id &&
        item.level === PermissionLevelEnum.OWNER,
    );
  if (permission) {
@@ -134,7 +141,7 @@
  const userPermission = gridApi.grid
    .getData()
    .find((item) => item.id === userStore.userInfo?.id);
    .find((item) => item.userId === userStore.userInfo?.id);
  if (!userPermission) {
    message.warning('你不是团队成员!');
    return;
src/views/crm/permission/modules/transfer-form.vue
@@ -103,12 +103,12 @@
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ bizType: number }>();
    if (!data || !data.bizType) {
    const data = modalApi.getData<{ bizType: number; id: number }>();
    if (!data?.bizType || !data.id) {
      return;
    }
    bizType.value = data.bizType;
    await formApi.setFieldValue('id', data.bizType);
    await formApi.setFieldValue('id', data.id);
  },
});
</script>
src/views/crm/receivable/components/data.ts
@@ -53,16 +53,6 @@
      minWidth: 150,
    },
    {
      title: '回款状态',
      field: 'auditStatus',
      minWidth: 100,
      fixed: 'right',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
      },
    },
    {
      title: '操作',
      field: 'actions',
      width: 130,
src/views/crm/receivable/data.ts
@@ -6,6 +6,7 @@
import { useUserStore } from '@vben/stores';
import { getContractSimpleList } from '#/api/crm/contract';
import { getInvoiceSimpleList } from '#/api/crm/invoice';
import {
  getReceivablePlan,
  getReceivablePlanSimpleList,
@@ -63,6 +64,7 @@
        placeholder: '请选择客户',
        onChange: () => {
          form.setFieldValue('contractId', undefined);
          form.setFieldValue('invoiceId', undefined);
          form.setFieldValue('planId', undefined);
          form.setFieldValue('price', undefined);
          form.setFieldValue('returnTime', undefined);
@@ -97,6 +99,7 @@
            })),
            placeholder: '请选择合同',
            onChange: (value: number) => {
              form.setFieldValue('invoiceId', undefined);
              form.setFieldValue('planId', undefined);
              form.setFieldValue('returnTime', undefined);
              form.setFieldValue('returnType', undefined);
@@ -113,10 +116,53 @@
      },
    },
    {
      fieldName: 'invoiceId',
      label: '关联开票',
      component: 'Select',
      rules: 'required',
      dependencies: {
        triggerFields: ['contractId', 'invoiceId'],
        disabled: (values) => !values.contractId || !!values.id,
        async componentProps(values, form) {
          if (!values.contractId) {
            return {
              options: [],
              placeholder: '请先选择合同',
            };
          }
          const invoices = await getInvoiceSimpleList(values.contractId);
          return {
            options: invoices.map((item) => {
              const remain = item.remainingInvoicePrice ?? item.price ?? 0;
              return {
                label: `${item.no} / ${item.invoiceNo ?? '-'} / Â¥${item.price ?? 0} / å‰©ä½™å¯å›žæ¬¾ Â¥${remain.toFixed(2)}`,
                value: item.id,
              };
            }),
            placeholder: '请选择关联开票',
            onChange: (value: number) => {
              form.setFieldValue('planId', undefined);
              form.setFieldValue('returnTime', undefined);
              form.setFieldValue('returnType', undefined);
              const invoice = invoices.find((item) => item.id === value);
              if (invoice) {
                form.setFieldValue(
                  'price',
                  invoice.remainingInvoicePrice ?? invoice.price ?? undefined,
                );
                form.setFieldValue('customerId', invoice.customerId);
              }
            },
          } as any;
        },
      },
    },
    {
      fieldName: 'planId',
      label: '回款期数',
      component: 'Select',
      rules: 'required',
      help: '此参数可在回款计划配置',
      dependencies: {
        triggerFields: ['contractId'],
        disabled: (values) => !values.contractId || values.id,
@@ -246,6 +292,12 @@
      slots: { default: 'contractNo' },
    },
    {
      title: '关联开票',
      field: 'invoice',
      minWidth: 200,
      slots: { default: 'invoiceNo' },
    },
    {
      title: '回款日期',
      field: 'returnTime',
      minWidth: 150,
@@ -303,16 +355,6 @@
      title: '创建人',
      field: 'creatorName',
      minWidth: 150,
    },
    {
      title: '回款状态',
      field: 'auditStatus',
      minWidth: 100,
      fixed: 'right',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
      },
    },
    {
      title: '操作',
src/views/crm/receivable/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { CrmReceivableApi } from '#/api/crm/receivable';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { getReceivable } from '#/api/crm/receivable';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'CrmReceivableApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<CrmReceivableApi.Receivable>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** å…³è”开票展示 */
function invoiceRender(_val: unknown, data?: CrmReceivableApi.Receivable) {
  if (!data?.invoice?.no) {
    return '-';
  }
  return `${data.invoice.no} / ${data.invoice.invoiceNo ?? '-'} / Â¥${
    data.invoice.price ?? 0
  }`;
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '回款编号', field: 'no', render: textRender },
    { label: '客户名称', field: 'customerName', render: textRender },
    {
      label: '合同编号',
      field: 'contract',
      render: (_val, data) => (data?.contract?.no ? data.contract.no : '-'),
    },
    { label: '关联开票', field: 'invoice', render: invoiceRender },
    {
      label: '回款日期',
      field: 'returnTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '回款金额(元)',
      field: 'price',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '回款方式',
      field: 'returnType',
      render: (val) =>
        h(DictTag, {
          type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE,
          value: val,
        }),
    },
    {
      label: '审批状态',
      field: 'auditStatus',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.CRM_AUDIT_STATUS, value: val }),
    },
    { label: '负责人', field: 'ownerUserName', render: textRender },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getReceivable(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- å›žæ¬¾åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">回款基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/crm/receivable/detail/data.ts
@@ -55,6 +55,14 @@
        val && data?.contract?.no ? data?.contract?.no : '',
    },
    {
      field: 'invoice',
      label: '关联开票',
      render: (val, data) =>
        val && data?.invoice?.no
          ? `${data.invoice.no} / ${data.invoice.invoiceNo ?? '-'} / Â¥${data.invoice.price ?? 0}`
          : '',
    },
    {
      field: 'returnTime',
      label: '回款日期',
      render: (val) => formatDateTime(val) as string,
src/views/crm/receivable/index.vue
@@ -15,7 +15,7 @@
  deleteReceivable,
  exportReceivable,
  getReceivablePage,
  submitReceivable,
  updateReceivableStatus,
} from '#/api/crm/receivable';
import { $t } from '#/locales';
@@ -76,15 +76,18 @@
  }
}
/** æäº¤å®¡æ ¸ */
async function handleSubmit(row: CrmReceivableApi.Receivable) {
/** æ›´æ–°å›žæ¬¾çŠ¶æ€ï¼ˆå®¡æ ¸/反审核) */
async function handleUpdateStatus(
  row: CrmReceivableApi.Receivable,
  status: number,
) {
  const hideLoading = message.loading({
    content: '提交审核中...',
    content: '更新状态中...',
    duration: 0,
  });
  try {
    await submitReceivable(row.id!);
    message.success('提交审核成功');
    await updateReceivableStatus(row.id!, status);
    message.success('更新状态成功');
    handleRefresh();
  } finally {
    hideLoading();
@@ -106,12 +109,9 @@
  push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: CrmReceivableApi.Receivable) {
  push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
/** æŸ¥çœ‹å¼€ç¥¨è¯¦æƒ… */
function handleInvoiceDetail(row: CrmReceivableApi.Receivable) {
  push({ name: 'CrmInvoiceDetail', params: { id: row.invoiceId } });
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -186,6 +186,18 @@
          {{ row.customerName }}
        </Button>
      </template>
      <template #invoiceNo="{ row }">
        <template v-if="row.invoice">
          <Button type="link" @click="handleInvoiceDetail(row)">
            {{ row.invoice.no }}
          </Button>
          <span class="mx-1">/</span>
          <span>{{ row.invoice.invoiceNo ?? '-' }}</span>
          <span class="mx-1">/</span>
          <span>Â¥{{ row.invoice.price ?? 0 }}</span>
        </template>
        <span v-else>--</span>
      </template>
      <template #contractNo="{ row }">
        <Button
          v-if="row.contract"
@@ -204,21 +216,24 @@
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['crm:receivable:update'],
              ifShow: row.auditStatus === 10,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审核',
              label: '审核',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['crm:receivable:update'],
              onClick: handleSubmit.bind(null, row),
              ifShow: row.auditStatus === 0,
              ifShow: row.auditStatus === 10,
              onClick: handleUpdateStatus.bind(null, row, 20),
            },
            {
              label: '查看审批',
              label: '反审核',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['crm:receivable:update'],
              onClick: handleProcessDetail.bind(null, row),
              ifShow: row.auditStatus !== 0,
              ifShow: row.auditStatus === 20,
              onClick: handleUpdateStatus.bind(null, row, 10),
            },
            {
              label: $t('common.delete'),
src/views/crm/receivable/modules/form.vue
@@ -1,11 +1,11 @@
<script lang="ts" setup>
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { getContractSimpleList } from '#/api/crm/contract';
import {
@@ -31,6 +31,7 @@
const emit = defineEmits(['success']);
const formData = ref<Partial<CrmReceivableApi.Receivable>>();
const showInvoiceHint = ref(false);
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['回款'])
@@ -48,6 +49,14 @@
  schema: useFormSchema(),
  showDefaultActions: false,
});
/** ç›‘听合同选择,提示先开票后回款 */
watch(
  () => formApi.form?.values?.contractId,
  (val) => {
    showInvoiceHint.value = !!val;
  },
);
/** æž„建新增回款的预填表单 */
async function buildCreateFormData(
@@ -135,6 +144,13 @@
<template>
  <Modal :title="getTitle" class="w-2/5">
    <Alert
      v-if="showInvoiceHint"
      type="warning"
      message="请先完成开票并上传发票附件,再进行回款操作"
      show-icon
      class="mb-4"
    />
    <Form class="mx-4" />
  </Modal>
</template>
src/views/crm/receivable/plan/data.ts
@@ -10,6 +10,7 @@
import { getSimpleUserList } from '#/api/system/user';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import dayjs from 'dayjs';
import { markRaw } from 'vue';
/** æ–°å¢ž/修改的表单 */
@@ -112,7 +113,7 @@
        valueFormat: 'YYYY-MM-DD',
        format: 'YYYY-MM-DD',
      },
      defaultValue: new Date(),
      defaultValue: dayjs().format('YYYY-MM-DD'),
    },
    {
      fieldName: 'remindDays',
src/views/crm/receivable/plan/modules/form.vue
@@ -26,6 +26,7 @@
const [Form, formApi] = useVbenForm({
  commonConfig: {
    labelWidth: 120,
    componentProps: {
      class: 'w-full',
    },
@@ -67,17 +68,19 @@
      return;
    }
    // åŠ è½½æ•°æ®
    formData.value = undefined;
    await formApi.resetForm();
    const data = modalApi.getData<CrmReceivablePlanApi.Plan>();
    if (!data || !data.id) {
      // è®¾ç½®åˆ° values
      await formApi.setValues(data);
    if (!data) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getReceivablePlan(data.id);
      if (data.id) {
        formData.value = await getReceivablePlan(data.id);
      }
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await formApi.setValues(formData.value ?? data);
    } finally {
      modalApi.unlock();
    }
@@ -86,7 +89,7 @@
</script>
<template>
  <Modal :title="getTitle" class="w-2/5">
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/crm/saleQuotation/index.vue
@@ -43,7 +43,7 @@
  formModalApi.setData(null).open();
}
/** AI è¯†åˆ«å½•å…¥ */
/** è¯†åˆ«å½•å…¥ */
function handleOcrCreate() {
  ocrUploadRef.value?.open();
}
@@ -159,7 +159,7 @@
              onClick: handleCreate,
            },
            {
              label: 'AI è¯†åˆ«å½•å…¥',
              label: '识别录入',
              type: 'default',
              auth: ['crm:sale-quotation:create'],
              onClick: handleOcrCreate,
src/views/crm/saleQuotation/modules/form.vue
@@ -155,7 +155,7 @@
      type="info"
      show-icon
      class="mx-4 mb-4"
      message="AI è¯†åˆ«åˆ°å®¢æˆ·åç§°ï¼Œè¯·åœ¨å®¢æˆ·é€‰æ‹©å™¨ä¸­æ‰‹åŠ¨åŒ¹é… CRM å®¢æˆ·"
      message="识别到客户名称,请在客户选择器中手动匹配 CRM å®¢æˆ·"
      :description="`识别结果:${ocrCustomerName}`"
    />
    <Form class="mx-4">
src/views/crm/saleQuotation/modules/ocr-upload.vue
@@ -49,7 +49,7 @@
    onSuccess({ blobId }, file);
    // å¼€å§‹ OCR è¯†åˆ«
    step.value = 1;
    processingTip.value = '正在进行 AI OCR è¯†åˆ«ï¼Œè¯·ç¨å€™...';
    processingTip.value = '正在进行报价单识别,请稍候...';
    const ocrData = await ocrSaleQuotation(blobId);
    // æ£€æŸ¥é”™è¯¯
    if (ocrData.rawText && !ocrData.name && !ocrData.customerName && ocrData.items?.length === 0) {
@@ -101,7 +101,7 @@
</script>
<template>
  <Modal v-model:open="open" title="AI è¯†åˆ«å½•入报价单" width="680px" :footer="null" @cancel="handleClose">
  <Modal v-model:open="open" title="识别录入报价单" width="680px" :footer="null" @cancel="handleClose">
    <!-- Step 0: ä¸Šä¼  -->
    <template v-if="step === 0">
      <Upload.Dragger
src/views/crm/salesTarget/analysis/chart-options.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,130 @@
import type { EChartsOption } from '@vben/plugins/echarts';
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
/** è¾¾æˆåˆ†æžæŒ‡æ ‡ Tab å®šä¹‰ */
export const ANALYSIS_TABS = [
  { key: 'contract', label: '合同金额', unit: '元' },
  { key: 'receivable', label: '回款金额', unit: '元' },
  { key: 'business', label: '商机金额', unit: '元' },
  { key: 'customer', label: '新增客户', unit: '个' },
  { key: 'contractCount', label: '新增合同', unit: '个' },
] as const;
export type AnalysisTabKey = (typeof ANALYSIS_TABS)[number]['key'];
type MetricValue = {
  target: number;
  actual: number;
  rate: number | null;
};
const METRICS: Record<
  AnalysisTabKey,
  { label: string; unit: string; pick: (item: CrmSalesTargetApi.SalesTarget) => MetricValue }
> = {
  contract: {
    label: '合同金额',
    unit: '元',
    pick: (item) => ({
      target: item.contractTarget ?? 0,
      actual: item.actualContractPrice ?? 0,
      rate: item.contractRate ?? null,
    }),
  },
  receivable: {
    label: '回款金额',
    unit: '元',
    pick: (item) => ({
      target: item.receivableTarget ?? 0,
      actual: item.actualReceivablePrice ?? 0,
      rate: item.receivableRate ?? null,
    }),
  },
  business: {
    label: '商机金额',
    unit: '元',
    pick: (item) => ({
      target: item.businessTarget ?? 0,
      actual: item.actualBusinessPrice ?? 0,
      rate: item.businessRate ?? null,
    }),
  },
  customer: {
    label: '新增客户',
    unit: '个',
    pick: (item) => ({
      target: item.customerTarget ?? 0,
      actual: item.actualCustomerCount ?? 0,
      rate: item.customerRate ?? null,
    }),
  },
  contractCount: {
    label: '新增合同',
    unit: '个',
    pick: (item) => ({
      target: item.contractCountTarget ?? 0,
      actual: item.actualContractCount ?? 0,
      rate: item.contractCountRate ?? null,
    }),
  },
};
function formatNumber(value: number | null | undefined): string {
  return value == null ? '-' : value.toLocaleString('zh-CN');
}
/** ç”ŸæˆæŒ‡æ ‡å¯¹æ¯”柱状图(目标 vs å®žé™…)配置 */
export function getChartOptions(
  tabKey: AnalysisTabKey,
  list: CrmSalesTargetApi.SalesTarget[],
): EChartsOption {
  const metric = METRICS[tabKey];
  const valueName = `${metric.label}(${metric.unit})`;
  return {
    dataset: {
      dimensions: ['name', 'target', 'actual', 'rate'],
      source: list.map((item) => {
        const value = metric.pick(item);
        return {
          name: item.name ?? '--',
          target: value.target,
          actual: value.actual,
          rate: value.rate,
        };
      }),
    },
    grid: { left: 20, right: 20, bottom: 20, containLabel: true },
    legend: { top: 10 },
    series: [
      { name: '目标', type: 'bar', barMaxWidth: 24 },
      { name: '实际', type: 'bar', barMaxWidth: 24 },
    ],
    toolbox: {
      feature: {
        saveAsImage: { show: true, name: `${metric.label}达成对比` },
      },
    },
    tooltip: {
      trigger: 'axis',
      axisPointer: { type: 'shadow' },
      formatter: (params: unknown) => {
        const list = params as Array<{
          data: { name: string; target: number; actual: number; rate: number | null };
        }>;
        const row = list[0]?.data;
        if (!row) {
          return '';
        }
        return [
          `<b>${row.name}</b>`,
          `目标:${formatNumber(row.target)}`,
          `实际:${formatNumber(row.actual)}`,
          `达成率:${row.rate == null ? '-' : `${row.rate}%`}`,
        ].join('<br/>');
      },
    },
    xAxis: { type: 'category', name: '目标名称' },
    yAxis: { type: 'value', name: valueName },
  } as EChartsOption;
}
src/views/crm/salesTarget/analysis/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,166 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
/** è¾¾æˆåˆ†æžçš„æŸ¥è¯¢è¡¨å• */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'periodType',
      label: '周期类型',
      component: 'Select',
      rules: 'required',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE, 'number'),
        placeholder: '请选择周期类型',
      },
      defaultValue: 1,
    },
    {
      fieldName: 'periodValue',
      label: '周期值',
      component: 'Input',
      rules: 'required',
      componentProps: {
        placeholder: '如 2026 / 2026-Q1 / 2026-07',
      },
      defaultValue: new Date().getFullYear().toString(),
    },
    {
      fieldName: 'targetDimension',
      label: '目标维度',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_DIMENSION, 'number'),
        placeholder: '全部',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_STATUS, 'number'),
        placeholder: '全部',
        allowClear: true,
      },
    },
  ];
}
/** è¾¾æˆåˆ†æžçš„表格字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'name',
      title: '目标名称',
      minWidth: 200,
      fixed: 'left',
    },
    {
      field: 'targetUserName',
      title: '目标对象',
      minWidth: 160,
      slots: { default: 'target' },
    },
    {
      field: 'contractTarget',
      title: '合同金额目标',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'actualContractPrice',
      title: '实际合同金额',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'contractRate',
      title: '合同达成率(%)',
      minWidth: 120,
      slots: { default: 'contractRate' },
    },
    {
      field: 'receivableTarget',
      title: '回款金额目标',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'actualReceivablePrice',
      title: '实际回款金额',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'receivableRate',
      title: '回款达成率(%)',
      minWidth: 120,
      slots: { default: 'receivableRate' },
    },
    {
      field: 'businessTarget',
      title: '商机金额目标',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'actualBusinessPrice',
      title: '实际商机金额',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'businessRate',
      title: '商机达成率(%)',
      minWidth: 120,
      slots: { default: 'businessRate' },
    },
    {
      field: 'customerTarget',
      title: '新增客户目标',
      minWidth: 120,
    },
    {
      field: 'actualCustomerCount',
      title: '实际新增客户',
      minWidth: 120,
    },
    {
      field: 'customerRate',
      title: '客户达成率(%)',
      minWidth: 120,
      slots: { default: 'customerRate' },
    },
    {
      field: 'contractCountTarget',
      title: '新增合同目标',
      minWidth: 120,
    },
    {
      field: 'actualContractCount',
      title: '实际新增合同',
      minWidth: 120,
    },
    {
      field: 'contractCountRate',
      title: '合同数达成率(%)',
      minWidth: 120,
      slots: { default: 'contractCountRate' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      fixed: 'right',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_SALES_TARGET_STATUS },
      },
    },
  ];
}
src/views/crm/salesTarget/analysis/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
import { onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSalesTargetAnalysis } from '#/api/crm/salesTarget';
import { $t } from '#/locales';
import {
  ANALYSIS_TABS,
  getChartOptions,
  type AnalysisTabKey,
} from './chart-options';
import { useGridColumns, useGridFormSchema } from './data';
const activeTab = ref<string>('contract');
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** æŒ‰å½“前表单值 + å½“前指标 Tab åŠ è½½å›¾è¡¨ä¸Žè¡¨æ ¼ */
async function handleLoad() {
  const formValues = await formApi.getValues();
  const res = await getSalesTargetAnalysis(formValues);
  await renderEcharts(getChartOptions(activeTab.value as AnalysisTabKey, res));
  await gridApi.grid.reloadData(res);
}
const [QueryForm, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
  },
  schema: useGridFormSchema(),
  showCollapseButton: true,
  submitButtonOptions: {
    content: $t('common.query'),
  },
  wrapperClass: 'grid-cols-1 md:grid-cols-4',
  handleSubmit: handleLoad,
});
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      autoLoad: false, // å›¾è¡¨ä¸Žè¡¨æ ¼ç»Ÿä¸€èµ° handleLoad æ‰‹åŠ¨åŠ è½½ï¼Œé¿å…ç©ºå‚æ•°è¯·æ±‚
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<CrmSalesTargetApi.SalesTarget>,
});
/** æŒ‡æ ‡ Tab åˆ‡æ¢ */
function handleTabChange(key: string | number) {
  activeTab.value = String(key);
  handleLoad();
}
onMounted(async () => {
  // é‡ç½®è¡¨å•为默认周期(当前年份),默认加载一次达成分析
  await formApi.resetForm();
  await handleLoad();
});
</script>
<template>
  <Page auto-content-height>
    <ContentWrap>
      <QueryForm />
      <Tabs
        v-model:active-key="activeTab"
        class="w-full"
        @change="handleTabChange"
      >
        <Tabs.TabPane
          v-for="item in ANALYSIS_TABS"
          :key="item.key"
          :tab="item.label"
          :force-render="true"
        />
      </Tabs>
      <EchartsUI class="mb-4 w-full" height="380px" ref="chartRef" />
      <Grid>
        <template #target="{ row }">
          <span>
            {{
              row.targetUserName ||
              row.targetDeptName ||
              row.targetItemName ||
              '--'
            }}
          </span>
        </template>
        <template #contractRate="{ row }">
          <span :class="row.contractRate == null ? '' : 'font-medium'">
            {{ row.contractRate == null ? '--' : `${row.contractRate}%` }}
          </span>
        </template>
        <template #receivableRate="{ row }">
          <span :class="row.receivableRate == null ? '' : 'font-medium'">
            {{ row.receivableRate == null ? '--' : `${row.receivableRate}%` }}
          </span>
        </template>
        <template #businessRate="{ row }">
          <span :class="row.businessRate == null ? '' : 'font-medium'">
            {{ row.businessRate == null ? '--' : `${row.businessRate}%` }}
          </span>
        </template>
        <template #customerRate="{ row }">
          <span :class="row.customerRate == null ? '' : 'font-medium'">
            {{ row.customerRate == null ? '--' : `${row.customerRate}%` }}
          </span>
        </template>
        <template #contractCountRate="{ row }">
          <span :class="row.contractCountRate == null ? '' : 'font-medium'">
            {{
              row.contractCountRate == null
                ? '--'
                : `${row.contractCountRate}%`
            }}
          </span>
        </template>
      </Grid>
    </ContentWrap>
  </Page>
</template>
src/views/crm/salesTarget/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,351 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { markRaw } from 'vue';
/** ç›®æ ‡ç»´åº¦æžšä¸¾ */
export const SALES_TARGET_DIMENSION = {
  USER: 1, // ä¸ªäºº
  DEPT: 2, // éƒ¨é—¨
  ITEM: 3, // äº§å“
} as const;
/** å‘¨æœŸç±»åž‹æžšä¸¾ */
export const SALES_TARGET_PERIOD_TYPE = {
  YEAR: 1, // å¹´
  QUARTER: 2, // å­£åº¦
  MONTH: 3, // æœˆ
} as const;
/** å‘¨æœŸå€¼è¾“入占位提示 */
const PERIOD_VALUE_PLACEHOLDER: Record<number, string> = {
  [SALES_TARGET_PERIOD_TYPE.YEAR]: '请输入年份,如 2026',
  [SALES_TARGET_PERIOD_TYPE.QUARTER]: '请输入季度,如 2026-Q1',
  [SALES_TARGET_PERIOD_TYPE.MONTH]: '请输入月份,如 2026-07',
};
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'name',
      label: '目标名称',
      component: 'Input',
      rules: 'required',
      componentProps: {
        placeholder: '请输入目标名称,如 2026 å¹´åº¦é”€å”®ç›®æ ‡',
        allowClear: true,
      },
    },
    {
      fieldName: 'periodType',
      label: '周期类型',
      component: 'Select',
      rules: 'required',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE, 'number'),
        placeholder: '请选择周期类型',
      },
    },
    {
      fieldName: 'periodValue',
      label: '周期值',
      component: 'Input',
      rules: 'required',
      dependencies: {
        triggerFields: ['periodType'],
        async componentProps(values) {
          const placeholder =
            PERIOD_VALUE_PLACEHOLDER[values.periodType] ?? '请输入周期值';
          return { placeholder };
        },
      },
    },
    {
      fieldName: 'targetDimension',
      label: '目标维度',
      component: 'Select',
      rules: 'required',
      componentProps: (_values, form) => ({
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_DIMENSION, 'number'),
        placeholder: '请选择目标维度',
        onChange: () => {
          form.setFieldValue('targetUserId', undefined);
          form.setFieldValue('targetDeptId', undefined);
          form.setFieldValue('targetItemId', undefined);
        },
      }),
    },
    {
      fieldName: 'targetUserId',
      label: '目标用户',
      component: 'ApiSelect',
      componentProps: {
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
        placeholder: '请选择目标用户',
        allowClear: true,
      },
      dependencies: {
        triggerFields: ['targetDimension'],
        show: (values) => values.targetDimension === SALES_TARGET_DIMENSION.USER,
      },
    },
    {
      fieldName: 'targetDeptId',
      label: '目标部门',
      component: 'ApiTreeSelect',
      componentProps: {
        api: async () => {
          const data = await getSimpleDeptList();
          return handleTree(data);
        },
        labelField: 'name',
        valueField: 'id',
        childrenField: 'children',
        treeDefaultExpandAll: true,
        placeholder: '请选择目标部门',
        allowClear: true,
      },
      dependencies: {
        triggerFields: ['targetDimension'],
        show: (values) => values.targetDimension === SALES_TARGET_DIMENSION.DEPT,
      },
    },
    {
      fieldName: 'targetItemId',
      label: '目标产品',
      component: markRaw(MdmItemSelect),
      componentProps: {
        placeholder: '请选择目标产品',
      },
      dependencies: {
        triggerFields: ['targetDimension'],
        show: (values) => values.targetDimension === SALES_TARGET_DIMENSION.ITEM,
      },
    },
    {
      fieldName: 'contractTarget',
      label: '合同金额目标(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入合同金额目标',
      },
    },
    {
      fieldName: 'receivableTarget',
      label: '回款金额目标(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入回款金额目标',
      },
    },
    {
      fieldName: 'businessTarget',
      label: '商机金额目标(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入商机金额目标',
      },
    },
    {
      fieldName: 'customerTarget',
      label: '新增客户数目标',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 0,
        placeholder: '请输入新增客户数目标',
      },
    },
    {
      fieldName: 'contractCountTarget',
      label: '新增合同数目标',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 0,
        placeholder: '请输入新增合同数目标',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '目标名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入目标名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'periodType',
      label: '周期类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE, 'number'),
        placeholder: '请选择周期类型',
        allowClear: true,
      },
    },
    {
      fieldName: 'periodValue',
      label: '周期值',
      component: 'Input',
      componentProps: {
        placeholder: '如 2026 / 2026-Q1 / 2026-07',
        allowClear: true,
      },
    },
    {
      fieldName: 'targetDimension',
      label: '目标维度',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_DIMENSION, 'number'),
        placeholder: '请选择目标维度',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'name',
      title: '目标名称',
      minWidth: 200,
      fixed: 'left',
    },
    {
      field: 'periodType',
      title: '周期类型',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE },
      },
    },
    {
      field: 'periodValue',
      title: '周期值',
      minWidth: 120,
    },
    {
      field: 'targetUserName',
      title: '目标对象',
      minWidth: 160,
      slots: { default: 'target' },
    },
    {
      field: 'contractTarget',
      title: '合同金额',
      minWidth: 200,
      slots: { default: 'contract' },
    },
    {
      field: 'receivableTarget',
      title: '回款金额',
      minWidth: 200,
      slots: { default: 'receivable' },
    },
    {
      field: 'businessTarget',
      title: '商机金额',
      minWidth: 200,
      slots: { default: 'business' },
    },
    {
      field: 'customerTarget',
      title: '新增客户',
      minWidth: 170,
      slots: { default: 'customer' },
    },
    {
      field: 'contractCountTarget',
      title: '新增合同',
      minWidth: 170,
      slots: { default: 'contractCount' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_SALES_TARGET_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 160,
      formatter: 'formatDateTime',
    },
    {
      field: 'creatorName',
      title: '创建人',
      minWidth: 120,
    },
    {
      title: '操作',
      width: 180,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/crm/salesTarget/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,221 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  changeSalesTargetStatus,
  deleteSalesTarget,
  exportSalesTarget,
  getSalesTargetPage,
} from '#/api/crm/salesTarget';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const formValues = await gridApi.formApi.getValues();
  const data = await exportSalesTarget(formValues);
  downloadFileFromBlobPart({ fileName: '销售目标.xls', source: data });
}
/** æ–°å¢žé”€å”®ç›®æ ‡ */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑销售目标 */
function handleEdit(row: CrmSalesTargetApi.SalesTarget) {
  formModalApi.setData({ salesTarget: row }).open();
}
/** ä½œåºŸ/恢复销售目标 */
async function handleChangeStatus(row: CrmSalesTargetApi.SalesTarget) {
  const nextStatus = row.status === 3 ? 0 : 3; // ä½œåºŸ <-> æ¢å¤
  const actionText = row.status === 3 ? '恢复' : '作废';
  const hideLoading = message.loading({
    content: `${actionText}中...`,
    duration: 0,
  });
  try {
    await changeSalesTargetStatus({ id: row.id!, status: nextStatus });
    message.success(`${actionText}成功`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** åˆ é™¤é”€å”®ç›®æ ‡ */
async function handleDelete(row: CrmSalesTargetApi.SalesTarget) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.name!]),
    duration: 0,
  });
  try {
    await deleteSalesTarget(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.name!]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getSalesTargetPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
      height: 64, // æ”¾å¤§è¡Œé«˜ï¼Œå®¹çº³ç›®æ ‡/实际/达成率三行内容
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<CrmSalesTargetApi.SalesTarget>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增销售目标',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['crm:sales-target:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['crm:sales-target:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #target="{ row }">
        <span>
          {{
            row.targetUserName ||
            row.targetDeptName ||
            row.targetItemName ||
            '--'
          }}
        </span>
      </template>
      <template #contract="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.contractTarget ?? '-' }}</div>
          <div>实际:{{ row.actualContractPrice ?? '-' }}</div>
          <div>达成率:{{ row.contractRate ?? '-' }}%</div>
        </div>
      </template>
      <template #receivable="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.receivableTarget ?? '-' }}</div>
          <div>实际:{{ row.actualReceivablePrice ?? '-' }}</div>
          <div>达成率:{{ row.receivableRate ?? '-' }}%</div>
        </div>
      </template>
      <template #business="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.businessTarget ?? '-' }}</div>
          <div>实际:{{ row.actualBusinessPrice ?? '-' }}</div>
          <div>达成率:{{ row.businessRate ?? '-' }}%</div>
        </div>
      </template>
      <template #customer="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.customerTarget ?? '-' }}</div>
          <div>实际:{{ row.actualCustomerCount ?? '-' }}</div>
          <div>达成率:{{ row.customerRate ?? '-' }}%</div>
        </div>
      </template>
      <template #contractCount="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.contractCountTarget ?? '-' }}</div>
          <div>实际:{{ row.actualContractCount ?? '-' }}</div>
          <div>达成率:{{ row.contractCountRate ?? '-' }}%</div>
        </div>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['crm:sales-target:update'],
              onClick: handleEdit.bind(null, row),
              ifShow: row.status !== 3,
            },
            {
              label: row.status === 3 ? '恢复' : '作废',
              type: 'link',
              auth: ['crm:sales-target:update'],
              popConfirm: {
                title:
                  row.status === 3 ? '确认恢复该目标?' : '确认作废该目标?',
                confirm: handleChangeStatus.bind(null, row),
              },
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['crm:sales-target:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name!]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/crm/salesTarget/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import {
  createSalesTarget,
  getSalesTarget,
  updateSalesTarget,
} from '#/api/crm/salesTarget';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
type SalesTargetFormModalData = {
  salesTarget?: Pick<CrmSalesTargetApi.SalesTarget, 'id'>;
};
const emit = defineEmits(['success']);
const formData = ref<Partial<CrmSalesTargetApi.SalesTarget>>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['销售目标'])
    : $t('ui.actionTitle.create', ['销售目标']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 150,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as CrmSalesTargetApi.SalesTarget;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formData.value?.id
        ? updateSalesTarget(data)
        : createSalesTarget(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    formData.value = undefined;
    await formApi.resetForm();
    const data = modalApi.getData() as null | SalesTargetFormModalData;
    if (!data?.salesTarget?.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getSalesTarget(data.salesTarget.id);
      if (formData.value) {
        await formApi.setValues(formData.value as any);
      }
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/crm/statistics/performance/chartOptions.ts
@@ -47,7 +47,7 @@
            yAxisIndex: 1,
            data: res.map((s: any) =>
              s.lastMonthCount === 0
                ? 'NULL'
                ? (s.currentMonthCount > 0 ? 100 : 0).toFixed(2)
                : (
                    ((s.currentMonthCount - s.lastMonthCount) /
                      s.lastMonthCount) *
@@ -61,7 +61,7 @@
            yAxisIndex: 1,
            data: res.map((s: any) =>
              s.lastYearCount === 0
                ? 'NULL'
                ? (s.currentMonthCount > 0 ? 100 : 0).toFixed(2)
                : (
                    ((s.currentMonthCount - s.lastYearCount) /
                      s.lastYearCount) *
@@ -166,7 +166,7 @@
            yAxisIndex: 1,
            data: res.map((s: any) =>
              s.lastMonthCount === 0
                ? 'NULL'
                ? (s.currentMonthCount > 0 ? 100 : 0).toFixed(2)
                : (
                    ((s.currentMonthCount - s.lastMonthCount) /
                      s.lastMonthCount) *
@@ -180,7 +180,7 @@
            yAxisIndex: 1,
            data: res.map((s: any) =>
              s.lastYearCount === 0
                ? 'NULL'
                ? (s.currentMonthCount > 0 ? 100 : 0).toFixed(2)
                : (
                    ((s.currentMonthCount - s.lastYearCount) /
                      s.lastYearCount) *
@@ -290,7 +290,7 @@
            yAxisIndex: 1,
            data: res.map((s: any) =>
              s.lastMonthCount === 0
                ? 'NULL'
                ? (s.currentMonthCount > 0 ? 100 : 0).toFixed(2)
                : (
                    ((s.currentMonthCount - s.lastMonthCount) /
                      s.lastMonthCount) *
@@ -304,7 +304,7 @@
            yAxisIndex: 1,
            data: res.map((s: any) =>
              s.lastYearCount === 0
                ? 'NULL'
                ? (s.currentMonthCount > 0 ? 100 : 0).toFixed(2)
                : (
                    ((s.currentMonthCount - s.lastYearCount) /
                      s.lastYearCount) *
src/views/crm/statistics/performance/index.vue
@@ -131,7 +131,7 @@
    tableData[2][`field${index}`] = item.lastYearCount;
    tableData[3][`field${index}`] =
      item.lastMonthCount === 0
        ? 'NULL'
        ? (item.currentMonthCount > 0 ? 100 : 0).toFixed(2)
        : (
            ((item.currentMonthCount - item.lastMonthCount) /
              item.lastMonthCount) *
@@ -139,7 +139,7 @@
          ).toFixed(2);
    tableData[4][`field${index}`] =
      item.lastYearCount === 0
        ? 'NULL'
        ? (item.currentMonthCount > 0 ? 100 : 0).toFixed(2)
        : (
            ((item.currentMonthCount - item.lastYearCount) /
              item.lastYearCount) *
src/views/dashboard/analytics/index.vue
@@ -95,7 +95,6 @@
      </Col>
      <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4">
        <AlertPanel
          :andon-active-count="summary.andonActiveCount"
          :repair-active-count="summary.repairActiveCount"
          @navigate="handleNavigate"
        />
src/views/dashboard/analytics/modules/alert-panel.vue
@@ -15,7 +15,6 @@
defineOptions({ name: 'DashboardAlertPanel' });
const props = defineProps<{
  andonActiveCount: number;
  repairActiveCount: number;
}>();
@@ -67,24 +66,7 @@
<template>
  <div class="space-y-4">
    <!-- é¢„警卡片行 -->
    <div class="grid grid-cols-2 gap-3">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
        @click="emit('navigate', 'MesProAndon')"
      >
        <div class="relative z-10 flex items-center gap-3">
          <div class="flex size-10 items-center justify-center rounded-lg bg-red-50 text-red-600 shadow-sm dark:bg-red-500/15 dark:text-red-400">
            <IconifyIcon class="size-5" icon="lucide:alert-triangle" />
          </div>
          <div>
            <div class="text-xs text-gray-500 dark:text-gray-400">安灯呼叫</div>
            <div class="flex items-baseline gap-1">
              <CountTo class="text-xl font-bold text-red-600 dark:text-red-400" :end-val="andonActiveCount" :duration="1000" />
              <span class="text-xs text-gray-400">未处置</span>
            </div>
          </div>
        </div>
      </div>
    <div class="grid grid-cols-1 gap-3">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
        @click="emit('navigate', 'MesProRepair')"
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -21,7 +21,6 @@
  { title: '质检管理', icon: 'lucide:clipboard-check', route: 'MesQcRecord', gradient: 'from-orange-500 to-amber-500', shadow: 'shadow-orange-500/15' },
  { title: '设备点检', icon: 'lucide:settings', route: 'MesDvMachinery', gradient: 'from-violet-500 to-purple-500', shadow: 'shadow-violet-500/15' },
  { title: '生产排程', icon: 'lucide:calendar-range', route: 'MesProScheduling', gradient: 'from-cyan-500 to-teal-500', shadow: 'shadow-cyan-500/15' },
  { title: '安灯呼叫', icon: 'lucide:bell-ring', route: 'MesProAndon', gradient: 'from-red-500 to-rose-500', shadow: 'shadow-red-500/15' },
  { title: '物料库存', icon: 'lucide:package-search', route: 'WlsMaterialStock', gradient: 'from-indigo-500 to-blue-500', shadow: 'shadow-indigo-500/15' },
  { title: '工艺路线', icon: 'lucide:route', route: 'MesPdRouting', gradient: 'from-teal-500 to-emerald-500', shadow: 'shadow-teal-500/15' },
  { title: '数据报表', icon: 'lucide:bar-chart-big', route: 'MesHomeIndex', gradient: 'from-pink-500 to-rose-500', shadow: 'shadow-pink-500/15' },
@@ -31,7 +30,7 @@
<template>
  <div class="glass-card rounded-2xl p-5">
    <h3 class="mb-4 text-sm font-semibold text-gray-800 dark:text-gray-100">快捷操作</h3>
    <div class="grid grid-cols-3 gap-3 sm:grid-cols-9">
    <div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
      <div
        v-for="action in actions"
        :key="action.route"
src/views/erp/finance/payment/data.ts
@@ -6,6 +6,7 @@
import { erpPriceInputFormatter } from '@vben/utils';
import { getAccountSimpleList } from '#/api/erp/finance/account';
import { getPurchaseInvoiceSimpleList } from '#/api/erp/purchase/invoice';
import { getSupplierSimpleList } from '#/api/srm/supplier';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -50,7 +51,7 @@
      fieldName: 'supplierId',
      label: '供应商',
      component: 'ApiSelect',
      componentProps: {
      componentProps: (_values, form) => ({
        disabled: formType === 'detail',
        placeholder: '请选择供应商',
        allowClear: true,
@@ -58,8 +59,54 @@
        api: getSupplierSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
        onChange: () => {
          // ä¾›åº”商变化后,清空已选的关联来票
          form.setFieldValue('invoiceId', undefined);
        },
      }),
      rules: 'required',
    },
    {
      fieldName: 'invoiceId',
      label: '关联来票',
      component: formType === 'detail' ? 'Input' : 'Select',
      componentProps:
        formType === 'detail'
          ? {
              disabled: true,
              placeholder: '--',
            }
          : undefined,
      rules: 'required',
      dependencies: {
        // ä¾èµ– invoiceId è‡ªèº«ï¼Œç¡®ä¿ç¼–辑回显时重新加载选项以匹配已选来票
        triggerFields: ['supplierId', 'invoiceId'],
        disabled: (values) => formType === 'detail' || !values.supplierId,
        async componentProps(values) {
          if (formType === 'detail') {
            return {};
          }
          if (!values.supplierId) {
            return {
              options: [],
              placeholder: '请先选择供应商',
            };
          }
          const invoices = await getPurchaseInvoiceSimpleList(
            values.supplierId,
          );
          return {
            options: invoices.map((item) => {
              const remain = item.remainingInvoicePrice ?? item.price ?? 0;
              return {
                label: `${item.no} / ${item.invoiceNo ?? '-'} / Â¥${item.price ?? 0} / å‰©ä½™å¯ä»˜ Â¥${remain.toFixed(2)}`,
                value: item.id,
              };
            }),
            placeholder: '请选择关联来票',
          } as any;
        },
      },
    },
    {
      fieldName: 'financeUserId',
@@ -105,16 +152,13 @@
      formItemClass: 'col-span-3',
    },
    {
      fieldName: 'accountId',
      fieldName: 'accountName',
      label: '付款账户',
      component: 'ApiSelect',
      component: 'Input',
      rules: 'required',
      componentProps: {
        placeholder: '请选择付款账户',
        placeholder: '请输入付款账户',
        allowClear: true,
        showSearch: true,
        api: getAccountSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
    },
    {
@@ -133,6 +177,7 @@
      fieldName: 'discountPrice',
      label: '优惠金额',
      component: 'InputNumber',
      defaultValue: 0,
      componentProps: {
        class: '!w-full',
        disabled: formType === 'detail',
@@ -165,15 +210,13 @@
  ];
}
/** è¡¨å•的明细表格列 */
export function useFormItemColumns(
  disabled: boolean,
): VxeTableGridOptions['columns'] {
/** è¡¨å•的明细表格列(付款基于来票,明细由来票自动生成,不可删除) */
export function useFormItemColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', minWidth: 50, fixed: 'left' },
    {
      field: 'bizNo',
      title: '采购单据编号',
      title: '来票编号',
      minWidth: 200,
    },
    {
@@ -200,13 +243,6 @@
      title: '备注',
      minWidth: 150,
      slots: { default: 'remark' },
    },
    {
      title: '操作',
      width: 50,
      fixed: 'right',
      slots: { default: 'actions' },
      visible: !disabled,
    },
  ];
}
@@ -335,6 +371,12 @@
      minWidth: 120,
    },
    {
      field: 'invoice',
      title: '关联来票',
      minWidth: 200,
      slots: { default: 'invoice' },
    },
    {
      field: 'paymentTime',
      title: '付款时间',
      width: 160,
src/views/erp/finance/payment/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpFinancePaymentApi } from '#/api/erp/finance/payment';
import type { DescriptionItemSchema } from '#/components/description';
import { h, nextTick, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getFinancePayment } from '#/api/erp/finance/payment';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'ErpFinancePaymentApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ErpFinancePaymentApi.FinancePayment>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** ä»˜æ¬¾æ˜Žç»†ï¼ˆå®¡æ‰¹è¯¦æƒ…只读列) */
function usePaymentItemColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', width: 60 },
    { field: 'bizNo', title: '来票编号', minWidth: 200 },
    {
      field: 'totalPrice',
      title: '应付金额',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'paidPrice',
      title: '已付金额',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'paymentPrice',
      title: '本次付款',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 150,
      formatter: ({ cellValue }) => textRender(cellValue),
    },
  ];
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: usePaymentItemColumns(),
    data: [],
    minHeight: 200,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'bizId',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  },
});
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '付款单号', field: 'no', render: textRender },
    { label: '供应商名称', field: 'supplierName', render: textRender },
    {
      label: '关联来票',
      field: 'invoice',
      render: (_val, data) => (data?.invoice?.no ? data.invoice.no : '-'),
    },
    {
      label: '付款时间',
      field: 'paymentTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '合计金额(元)',
      field: 'totalPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '优惠金额(元)',
      field: 'discountPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '实际付款金额(元)',
      field: 'paymentPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    { label: '付款账户', field: 'accountName', render: textRender },
    { label: '财务人员', field: 'financeUserName', render: textRender },
    {
      label: '审批状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.ERP_AUDIT_STATUS, value: val }),
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getFinancePayment(Number(props.id));
    if (formData.value?.items?.length) {
      await nextTick();
      await gridApi.grid.reloadData(formData.value.items);
    }
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- ä»˜æ¬¾å•基本信息 -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">付款单基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- ä»˜æ¬¾æ˜Žç»† -->
        <div
          v-if="formData?.items?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">付款明细</span>
          </div>
          <Grid />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/erp/finance/payment/index.vue
@@ -3,6 +3,7 @@
import type { ErpFinancePaymentApi } from '#/api/erp/finance/payment';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
@@ -13,19 +14,27 @@
import {
  deleteFinancePayment,
  exportFinancePayment,
  getFinancePaymentApproveProcessList,
  getFinancePaymentPage,
  updateFinancePaymentStatus,
} from '#/api/erp/finance/payment';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
/** ERP ä»˜æ¬¾å•列表 */
defineOptions({ name: 'ErpFinancePayment' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
@@ -65,22 +74,36 @@
  }
}
/** å®¡æ‰¹/反审批操作 */
async function handleUpdateStatus(
  row: ErpFinancePaymentApi.FinancePayment,
  status: number,
) {
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: ErpFinancePaymentApi.FinancePayment) {
  const hideLoading = message.loading({
    content: `确定${status === 20 ? '审批' : '反审批'}该付款单吗?`,
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    await updateFinancePaymentStatus(row.id!, status);
    message.success(`${status === 20 ? '审批' : '反审批'}成功`);
    handleRefresh();
  } finally {
    const processList = await getFinancePaymentApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先配置 BPM æµç¨‹');
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: ErpFinancePaymentApi.FinancePayment) {
  if (!row.processInstanceId) {
    message.warning('该付款单尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const checkedIds = ref<number[]>([]);
@@ -134,6 +157,7 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="付款单列表">
      <template #toolbar-tools>
        <TableAction
@@ -167,6 +191,13 @@
          ]"
        />
      </template>
      <template #invoice="{ row }">
        <span v-if="row.invoice">
          {{ row.invoice.no
          }}{{ row.invoice.invoiceNo ? ` / ${row.invoice.invoiceNo}` : '' }}
        </span>
        <span v-else>--</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
@@ -182,22 +213,24 @@
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['erp:finance-payment:update'],
              ifShow: () => row.status !== 20,
              ifShow: () => row.status !== 20 && row.status !== 15,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: row.status === 10 ? '审批' : '反审批',
              label: '提交审核',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['erp:finance-payment:update-status'],
              popConfirm: {
                title: `确认${row.status === 10 ? '审批' : '反审批'}${row.no}吗?`,
                confirm: handleUpdateStatus.bind(
                  null,
                  row,
                  row.status === 10 ? 20 : 10,
                ),
              },
              auth: ['erp:finance-payment:update'],
              ifShow: () => row.status === 10,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['erp:finance-payment:query'],
              ifShow: () => row.status !== 10 && !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
@@ -205,6 +238,7 @@
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['erp:finance-payment:delete'],
              ifShow: () => row.status !== 15,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, [row.id!]),
src/views/erp/finance/payment/modules/form.vue
@@ -17,6 +17,7 @@
  getFinancePayment,
  updateFinancePayment,
} from '#/api/erp/finance/payment';
import { getPurchaseInvoiceSimpleList } from '#/api/erp/purchase/invoice';
import { useFormSchema } from '../data';
import ItemForm from './item-form.vue';
@@ -28,7 +29,7 @@
  id: undefined,
  no: '',
  supplierId: undefined,
  accountId: undefined,
  accountName: undefined,
  financeUserId: undefined,
  paymentTime: undefined,
  remark: '',
@@ -68,6 +69,11 @@
      if (changedFields.includes('supplierId')) {
        formData.value.supplierId = values.supplierId;
      }
      // å…³è”来票变化时,加载来票信息供明细自动生成
      if (changedFields.includes('invoiceId')) {
        formData.value.invoiceId = values.invoiceId;
        handleInvoiceChange(values.invoiceId);
      }
      // ç›®çš„:同步到 item-form ç»„件,触发整体的价格计算
      if (changedFields.includes('discountPrice')) {
        formData.value.discountPrice = values.discountPrice;
@@ -80,6 +86,27 @@
    }
  },
});
/** å…³è”来票变化时,加载来票信息,供付款明细自动生成 */
async function handleInvoiceChange(invoiceId: number | undefined) {
  if (formType.value === 'detail' || !invoiceId) {
    if (!invoiceId) {
      formData.value.invoice = undefined;
    }
    return;
  }
  const supplierId = formData.value.supplierId;
  if (!supplierId) {
    return;
  }
  const invoices = await getPurchaseInvoiceSimpleList(supplierId);
  // è¯·æ±‚期间来票已再次切换时,丢弃过期结果,避免回显错乱
  if (formData.value.invoiceId !== invoiceId) {
    return;
  }
  const invoice = invoices.find((item) => item.id === invoiceId);
  formData.value.invoice = invoice;
}
/** æ›´æ–°ä»˜æ¬¾é¡¹ */
function handleUpdateItems(items: ErpFinancePaymentApi.FinancePaymentItem[]) {
@@ -149,11 +176,11 @@
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(formType.value));
    if (!data || !data.id) {
      // æ–°å¢žæ—¶ï¼Œé»˜è®¤é€‰ä¸­è´¦æˆ·
      // æ–°å¢žæ—¶ï¼Œé»˜è®¤å¡«å…¥é»˜è®¤ç»“算账户名称
      const accountList = await getAccountSimpleList();
      const defaultAccount = accountList.find((item) => item.defaultStatus);
      if (defaultAccount) {
        await formApi.setValues({ accountId: defaultAccount.id });
        await formApi.setValues({ accountName: defaultAccount.name });
      }
      return;
    }
@@ -162,6 +189,10 @@
      formData.value = await getFinancePayment(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value, false);
      // è¯¦æƒ…模式下,关联来票字段用 Input å±•示来票编号
      if (formType.value === 'detail' && formData.value?.invoice) {
        await formApi.setFieldValue('invoiceId', formData.value.invoice.no);
      }
      if (formData.value?.attachmentList?.length) {
        const blobIds = formData.value.attachmentList.map((item) => ({
          uid: String(item.id),
@@ -194,6 +225,7 @@
          :supplier-id="formData?.supplierId"
          :disabled="formType === 'detail'"
          :discount-price="formData?.discountPrice ?? 0"
          :invoice="formData?.invoice"
          @update:items="handleUpdateItems"
          @update:total-price="handleUpdateTotalPrice"
          @update:payment-price="handleUpdatePaymentPrice"
src/views/erp/finance/payment/modules/item-form.vue
@@ -1,26 +1,30 @@
<script lang="ts" setup>
import type { ErpFinancePaymentApi } from '#/api/erp/finance/payment';
import type { ErpPurchaseInApi } from '#/api/erp/purchase/in';
import type { ErpPurchaseReturnApi } from '#/api/erp/purchase/return';
import { computed, nextTick, ref, watch } from 'vue';
import { ErpBizType } from '@vben/constants';
import { erpPriceInputFormatter } from '@vben/utils';
import { Input, InputNumber, message } from 'ant-design-vue';
import { Input, InputNumber } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useFormItemColumns } from '../data';
import PurchaseInSelect from './purchase-in-select.vue';
import SaleReturnSelect from './sale-return-select.vue';
interface InvoiceForItem {
  id?: number;
  no?: string;
  price?: number;
  remainingInvoicePrice?: number;
}
interface Props {
  items?: ErpFinancePaymentApi.FinancePaymentItem[];
  supplierId?: number;
  disabled?: boolean;
  discountPrice?: number;
  invoice?: InvoiceForItem;
}
const props = withDefaults(defineProps<Props>(), {
@@ -28,6 +32,7 @@
  supplierId: undefined,
  disabled: false,
  discountPrice: 0,
  invoice: undefined,
});
const emit = defineEmits([
@@ -36,7 +41,18 @@
  'update:paymentPrice',
]);
/** è¾“入时不补零,失焦后仍按金额格式保留两位小数 */
const paymentPriceFormatter = (
  value: number | string | undefined,
  info: { input: string; userTyping: boolean },
) => {
  return info.userTyping ? info.input : erpPriceInputFormatter(value);
};
const tableData = ref<ErpFinancePaymentApi.FinancePaymentItem[]>([]); // è¡¨æ ¼æ•°æ®
/** å·²æ ¹æ®è¯¥æ¥ç¥¨ç”Ÿæˆçš„æ˜Žç»†ï¼Œé¿å…é‡å¤ç”Ÿæˆ */
const generatedInvoiceId = ref<number | undefined>();
/** èŽ·å–è¡¨æ ¼åˆè®¡æ•°æ® */
const summaries = computed(() => {
@@ -59,7 +75,7 @@
/** è¡¨æ ¼é…ç½® */
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useFormItemColumns(props.disabled),
    columns: useFormItemColumns(),
    data: tableData.value,
    minHeight: 250,
    autoResize: true,
@@ -85,11 +101,44 @@
      return;
    }
    tableData.value = [...items];
    // ç¼–辑回显时,标记已根据当前来票生成过,避免重复生成
    generatedInvoiceId.value = props.invoice?.id;
    await nextTick(); // ç‰¹æ®Šï¼šä¿è¯ gridApi å·²ç»åˆå§‹åŒ–
    await gridApi.grid.reloadData(tableData.value);
  },
  {
    immediate: true,
  },
);
/** å…³è”来票变化时,自动生成唯一的付款明细(付款基于来票) */
watch(
  () => props.invoice?.id,
  async (newId) => {
    if (!newId || !props.invoice) {
      return;
    }
    if (generatedInvoiceId.value === newId) {
      return;
    }
    generatedInvoiceId.value = newId;
    const invoice = props.invoice;
    const price = invoice.price ?? 0;
    const remaining = invoice.remainingInvoicePrice ?? price;
    tableData.value = [
      {
        bizId: invoice.id ?? 0,
        bizType: ErpBizType.PURCHASE_INVOICE,
        bizNo: invoice.no ?? '',
        totalPrice: price,
        paidPrice: price - remaining,
        paymentPrice: remaining,
        remark: '',
      },
    ];
    await nextTick();
    await gridApi.grid.reloadData(tableData.value);
    emit('update:items', [...tableData.value]);
  },
);
@@ -116,74 +165,6 @@
  { deep: true },
);
/** æ·»åŠ é‡‡è´­å…¥åº“å• */
const purchaseInSelectRef = ref();
const handleOpenPurchaseIn = () => {
  if (!props.supplierId) {
    message.error('请选择供应商');
    return;
  }
  purchaseInSelectRef.value?.open(props.supplierId);
};
const handleAddPurchaseIn = (rows: ErpPurchaseInApi.PurchaseIn[]) => {
  rows.forEach((row) => {
    const totalPrice = row.totalPrice ?? 0;
    const paidPrice = row.paymentPrice ?? 0;
    const newItem: ErpFinancePaymentApi.FinancePaymentItem = {
      bizId: row.id ?? 0,
      bizType: ErpBizType.PURCHASE_IN,
      bizNo: row.no ?? '',
      totalPrice,
      paidPrice,
      paymentPrice: totalPrice - paidPrice,
      remark: undefined,
    };
    tableData.value.push(newItem);
  });
  emit('update:items', [...tableData.value]);
};
/** æ·»åŠ é‡‡è´­é€€è´§å• */
const saleReturnSelectRef = ref();
const handleOpenSaleReturn = () => {
  if (!props.supplierId) {
    message.error('请选择供应商');
    return;
  }
  saleReturnSelectRef.value?.open(props.supplierId);
};
const handleAddSaleReturn = (rows: ErpPurchaseReturnApi.PurchaseReturn[]) => {
  rows.forEach((row) => {
    const totalPrice = row.totalPrice ?? 0;
    const refundPrice = row.refundPrice ?? 0;
    const newItem: ErpFinancePaymentApi.FinancePaymentItem = {
      bizId: row.id ?? 0,
      bizType: ErpBizType.PURCHASE_RETURN,
      bizNo: row.no ?? '',
      totalPrice: -totalPrice,
      paidPrice: -refundPrice,
      paymentPrice: -totalPrice + refundPrice,
      remark: undefined,
    };
    tableData.value.push(newItem);
  });
  emit('update:items', [...tableData.value]);
};
/** åˆ é™¤è¡Œ */
const handleDelete = async (row: any) => {
  const index = tableData.value.findIndex(
    (item) => item.bizId === row.bizId && item.bizType === row.bizType,
  );
  if (index !== -1) {
    tableData.value.splice(index, 1);
  }
  // é€šçŸ¥çˆ¶ç»„ä»¶æ›´æ–°
  emit('update:items', [...tableData.value]);
};
/** å¤„理行数据变更 */
const handleRowChange = (row: any) => {
  const index = tableData.value.findIndex(
@@ -201,7 +182,7 @@
const validate = () => {
  // æ£€æŸ¥æ˜¯å¦æœ‰æ˜Žç»†
  if (tableData.value.length === 0) {
    throw new Error('请添加付款明细');
    throw new Error('请先选择关联来票');
  }
  // æ£€æŸ¥æ¯è¡Œçš„付款金额
  for (let i = 0; i < tableData.value.length; i++) {
@@ -223,7 +204,7 @@
          v-model:value="row.paymentPrice"
          :precision="2"
          :disabled="disabled"
          :formatter="erpPriceInputFormatter"
          :formatter="paymentPriceFormatter"
          placeholder="请输入本次付款"
          @change="handleRowChange(row)"
        />
@@ -236,21 +217,6 @@
          @change="handleRowChange(row)"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '删除',
              type: 'link',
              danger: true,
              popConfirm: {
                title: '确认删除该付款明细吗?',
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
      <template #bottom>
        <div class="mt-2 rounded border border-border bg-muted p-2">
@@ -258,7 +224,7 @@
            <span class="font-medium text-foreground">合计:</span>
            <div class="flex space-x-4">
              <span>
                åˆè®¡ä»˜æ¬¾ï¼š{{ erpPriceInputFormatter(summaries.totalPrice) }}
                åº”付金额:{{ erpPriceInputFormatter(summaries.totalPrice) }}
              </span>
              <span>
                å·²ä»˜é‡‘额:{{ erpPriceInputFormatter(summaries.paidPrice) }}
@@ -270,34 +236,13 @@
            </div>
          </div>
        </div>
        <TableAction
          v-if="!disabled"
          class="mt-2 flex justify-center"
          :actions="[
            {
              label: '添加采购入库单',
              type: 'default',
              onClick: handleOpenPurchaseIn,
            },
            {
              label: '添加采购退货单',
              type: 'default',
              onClick: handleOpenSaleReturn,
            },
          ]"
        />
        <div
          v-if="!invoice && !disabled"
          class="mt-2 flex justify-center text-sm text-muted-foreground"
        >
          è¯·å…ˆåœ¨ä¸Šæ–¹è¡¨å•选择关联来票,系统将自动生成付款明细
        </div>
      </template>
    </Grid>
    <!-- é‡‡è´­å…¥åº“单选择组件 -->
    <PurchaseInSelect
      ref="purchaseInSelectRef"
      @success="handleAddPurchaseIn"
    />
    <!-- é‡‡è´­é€€è´§å•选择组件 -->
    <SaleReturnSelect
      ref="saleReturnSelectRef"
      @success="handleAddSaleReturn"
    />
  </div>
</template>
src/views/erp/finance/payment/modules/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { submitFinancePayment } from '#/api/erp/finance/payment';
interface ProcessDefinition {
  id: string;
  name: string;
  version: number;
  key: string;
}
const emit = defineEmits(['success']);
const paymentId = ref<number>();
const processList = ref<ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
const processOptions = computed(() => {
  return processList.value.map((p) => ({
    label: `${p.name} (V${p.version})`,
    value: p.key,
  }));
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    modalApi.lock();
    try {
      await submitFinancePayment(paymentId.value!, selectedProcessKey.value);
      await modalApi.close();
      emit('success');
      message.success('提交成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      paymentId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data = modalApi.getData<{ id: number; processList: ProcessDefinition[] }>();
    paymentId.value = data.id;
    processList.value = data.processList || [];
    // é»˜è®¤é€‰æ‹©ç¬¬ä¸€ä¸ªæµç¨‹
    if (processList.value.length > 0) {
      selectedProcessKey.value = processList.value[0].key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
        v-model:value="selectedProcessKey"
        :options="processOptions"
        style="width: 200px"
        placeholder="请选择审批流程"
      />
    </div>
  </Modal>
</template>
src/views/erp/finance/payment/modules/purchase-in-select.vue
ÎļþÒÑɾ³ý
src/views/erp/finance/payment/modules/sale-return-select.vue
ÎļþÒÑɾ³ý
src/views/erp/purchase/invoice/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,250 @@
import type { Ref } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getSupplierSimpleList } from '#/api/srm/supplier';
import { z } from '#/adapter/form';
import { markRaw } from 'vue';
import PurchaseOrderSelect from './modules/purchase-order-select.vue';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(
  orderRemainInvoicePrice?: Ref<number | undefined>,
): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '来票编号',
      component: 'Input',
      componentProps: {
        placeholder: '保存时自动生成',
        disabled: true,
      },
    },
    {
      fieldName: 'purchaseOrderId',
      label: '采购订单',
      component: markRaw(PurchaseOrderSelect),
      rules: 'required',
      dependencies: {
        triggerFields: ['id'],
        disabled: (values) => !!values.id,
      },
      componentProps: (_values, form) => ({
        placeholder: '请选择采购订单',
        onChange: (
          order:
            | { supplierName?: string; remainInvoicePrice?: number }
            | undefined,
        ) => {
          form.setFieldValue('supplierName', order?.supplierName);
          if (orderRemainInvoicePrice) {
            orderRemainInvoicePrice.value = order?.remainInvoicePrice;
          }
        },
      }),
    },
    {
      fieldName: 'supplierName',
      label: '供应商',
      component: 'Input',
      componentProps: {
        placeholder: '选择采购订单后自动带出',
        disabled: true,
      },
    },
    {
      fieldName: 'invoiceNo',
      label: '发票号码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入发票号码',
        allowClear: true,
      },
    },
    {
      fieldName: 'invoiceTitle',
      label: '发票抬头',
      component: 'Input',
      componentProps: {
        placeholder: '请输入发票抬头',
        allowClear: true,
      },
    },
    {
      fieldName: 'price',
      label: '来票金额(元)',
      component: 'InputNumber',
      rules: 'required',
      description: () =>
        orderRemainInvoicePrice?.value != null
          ? `采购订单未开票金额:${Number(orderRemainInvoicePrice.value).toFixed(2)} å…ƒ`
          : '',
      componentProps: {
        class: '!w-full',
        placeholder: '请输入来票金额',
        min: 0,
        precision: 2,
      },
      dependencies: {
        triggerFields: ['purchaseOrderId'],
        componentProps: () => ({
          class: '!w-full',
          placeholder: '请输入来票金额',
          min: 0,
          max: orderRemainInvoicePrice?.value ?? undefined,
          precision: 2,
        }),
        rules: () => {
          const max = orderRemainInvoicePrice?.value;
          if (max != null) {
            return z
              .number({ message: '请输入来票金额' })
              .min(0, '来票金额不能小于 0')
              .max(max, `来票金额不能大于未开票金额 ${Number(max).toFixed(2)}`);
          }
          return z
            .number({ message: '请输入来票金额' })
            .min(0, '来票金额不能小于 0');
        },
      },
    },
    {
      fieldName: 'invoiceTime',
      label: '来票日期',
      component: 'DatePicker',
      rules: 'required',
      componentProps: {
        placeholder: '请选择来票日期',
        showTime: false,
        valueFormat: 'YYYY-MM-DD',
        format: 'YYYY-MM-DD',
      },
    },
    {
      fieldName: 'blobIds',
      label: '发票附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        accept: ['jpg', 'jpeg', 'png', 'pdf'],
        helpText: '支持jpg、jpeg、png、pdf格式,单个文件不超过50MB',
      },
      formItemClass: 'md:col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 4,
      },
      formItemClass: 'md:col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '来票编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入来票编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'supplierId',
      label: '供应商',
      component: 'ApiSelect',
      componentProps: {
        placeholder: '请选择供应商',
        allowClear: true,
        showSearch: true,
        api: getSupplierSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      title: '来票编号',
      field: 'no',
      minWidth: 160,
      fixed: 'left',
      slots: { default: 'no' },
    },
    {
      title: '供应商',
      field: 'supplierName',
      minWidth: 150,
    },
    {
      title: '采购订单编号',
      field: 'order',
      minWidth: 160,
      slots: { default: 'orderNo' },
    },
    {
      title: '发票号码',
      field: 'invoiceNo',
      minWidth: 150,
    },
    {
      title: '来票金额(元)',
      field: 'price',
      minWidth: 150,
      formatter: 'formatAmount2',
    },
    {
      title: '来票日期',
      field: 'invoiceTime',
      minWidth: 150,
      formatter: 'formatDateTime',
    },
    {
      title: '备注',
      field: 'remark',
      minWidth: 150,
    },
    {
      title: '附件',
      field: 'attachmentList',
      minWidth: 100,
      fixed: 'right',
      slots: { default: 'attachment' },
    },
    {
      title: '操作',
      field: 'actions',
      minWidth: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/erp/purchase/invoice/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
import type { DescriptionItemSchema } from '#/components/description';
import { onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { getPurchaseInvoice } from '#/api/erp/purchase/invoice';
import { useDescription } from '#/components/description';
defineOptions({ name: 'ErpPurchaseInvoiceApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ErpPurchaseInvoiceApi.PurchaseInvoice>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '来票编号', field: 'no', render: textRender },
    { label: '供应商名称', field: 'supplierName', render: textRender },
    {
      label: '采购订单号',
      field: 'order',
      render: (_val, data) => (data?.order?.no ? data.order.no : '-'),
    },
    { label: '发票号码', field: 'invoiceNo', render: textRender },
    { label: '发票抬头', field: 'invoiceTitle', render: textRender },
    {
      label: '来票金额(元)',
      field: 'price',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '剩余可付金额(元)',
      field: 'remainingInvoicePrice',
      render: (val) => erpPriceInputFormatter(val ?? 0) || '-',
    },
    {
      label: '开票日期',
      field: 'invoiceTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getPurchaseInvoice(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- æ¥ç¥¨åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">来票基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/erp/purchase/invoice/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,194 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tag } from 'ant-design-vue';
import { nextTick, ref } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deletePurchaseInvoice,
  exportPurchaseInvoice,
  getPurchaseInvoicePage,
} from '#/api/erp/purchase/invoice';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import OcrUpload from './modules/ocr-upload.vue';
defineOptions({ name: 'ErpPurchaseInvoice' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const ocrUploadRef = ref<InstanceType<typeof OcrUpload>>();
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const formValues = await gridApi.formApi.getValues();
  const data = await exportPurchaseInvoice(formValues);
  downloadFileFromBlobPart({ fileName: '来票.xls', source: data });
}
/** åˆ›å»ºæ¥ç¥¨ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** è¯†åˆ«å½•å…¥ */
function handleOcrCreate() {
  ocrUploadRef.value?.open();
}
/** OCR è¯†åˆ«ç»“果确认 */
function handleOcrSuccess(data: ErpPurchaseInvoiceAiApi.OcrResultVO) {
  nextTick(() => {
    formModalApi.setData({ formType: 'create', ocrData: data } as any).open();
  });
}
/** ç¼–辑来票 */
function handleEdit(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹æ¥ç¥¨è¯¦æƒ… */
function handleDetail(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤æ¥ç¥¨ */
async function handleDelete(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.no]),
    duration: 0,
  });
  try {
    await deletePurchaseInvoice(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getPurchaseInvoicePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ErpPurchaseInvoiceApi.PurchaseInvoice>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
    <Grid table-title="来票列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['来票']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['erp:purchase-invoice:create'],
              onClick: handleCreate,
            },
            {
              label: '识别录入',
              type: 'primary',
              auth: ['erp:purchase-invoice:create'],
              onClick: handleOcrCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['erp:purchase-invoice:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #no="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.no }}
        </Button>
      </template>
      <template #orderNo="{ row }">
        <span>{{ row.order?.no ?? '--' }}</span>
      </template>
      <template #attachment="{ row }">
        <Tag v-if="!row.hasAttachment" color="warning">未上传</Tag>
        <Tag v-else color="success">已上传</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['erp:purchase-invoice:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['erp:purchase-invoice:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['erp:purchase-invoice:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/erp/purchase/invoice/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,139 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createPurchaseInvoice,
  getPurchaseInvoice,
  updatePurchaseInvoice,
} from '#/api/erp/purchase/invoice';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<ErpPurchaseInvoiceApi.PurchaseInvoice>();
const formType = ref<FormType>('create');
const ocrSupplierName = ref('');
const orderRemainInvoicePrice = ref<number>(); // æ‰€é€‰é‡‡è´­è®¢å•剩余可开票金额(未开票金额,用于提示与校验)
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['来票']);
  } else if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['来票']);
  } else {
    return '来票详情';
  }
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(orderRemainInvoicePrice),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as ErpPurchaseInvoiceApi.PurchaseInvoice;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formData.value?.id
        ? updatePurchaseInvoice(data)
        : createPurchaseInvoice(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      ocrSupplierName.value = '';
      orderRemainInvoicePrice.value = undefined;
      return;
    }
    const data = modalApi.getData<{
      formType: FormType;
      id?: number;
      ocrData?: ErpPurchaseInvoiceAiApi.OcrResultVO;
    }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(orderRemainInvoicePrice));
    await formApi.resetForm();
    // OCR è¯†åˆ«æ•°æ®é¢„填(新建场景,编辑/详情不处理)
    const ocr = data?.ocrData;
    ocrSupplierName.value = ocr?.supplierName || '';
    if (ocr) {
      await formApi.setValues({
        invoiceNo: ocr.invoiceNo,
        invoiceTitle: ocr.invoiceTitle,
        price: ocr.price,
        invoiceTime: ocr.invoiceTime,
        remark: ocr.remark,
      });
    }
    if (!data || !data.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getPurchaseInvoice(data.id);
      if (formData.value?.attachmentList?.length) {
        // è½¬ä¸ºä¸Šä¼ ç»„件的文件对象;提交时 formApi ä¼šæŒ‰ valueKey æå–附件 ID
        (formData.value as Record<string, unknown>).blobIds =
          formData.value.attachmentList.map((item) => ({
            uid: String(item.id),
            name: item.name || '',
            url: item.url || '',
            status: 'done',
            id: item.id,
          }));
      }
      await formApi.setValues(formData.value as any);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      v-if="ocrSupplierName"
      type="info"
      show-icon
      class="mx-4 mb-4"
      message="识别到销售方名称,请选择采购订单并核对订单供应商是否一致"
      :description="`识别结果:${ocrSupplierName}`"
    />
    <Form class="mx-4" />
  </Modal>
</template>
src/views/erp/purchase/invoice/modules/ocr-upload.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
<script lang="ts" setup>
import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
  Alert,
  Button,
  Descriptions,
  Modal,
  Tag,
  Upload,
  message,
} from 'ant-design-vue';
import { ocrPurchaseInvoice } from '#/api/erp/purchase/invoice/ai';
import { uploadFile } from '#/api/system/storage';
defineOptions({ name: 'ErpPurchaseInvoiceOcrUpload' });
const emit = defineEmits<{
  success: [data: ErpPurchaseInvoiceAiApi.OcrResultVO];
}>();
const open = ref(false);
const step = ref(0); // 0=upload, 1=processing, 2=preview
const fileList = ref<any[]>([]);
const ocrResult = ref<ErpPurchaseInvoiceAiApi.OcrResultVO>();
const processingTip = ref('');
function handleOpen() {
  open.value = true;
  step.value = 0;
  fileList.value = [];
  ocrResult.value = undefined;
}
/** è‡ªå®šä¹‰ä¸Šä¼  */
async function handleUpload(options: any) {
  const { file, onSuccess, onError } = options;
  try {
    const result = await uploadFile([file as File]);
    const blobId = result?.[0]?.id;
    if (!blobId) {
      onError(new Error('上传失败'));
      return;
    }
    onSuccess({ blobId }, file);
    // å¼€å§‹ OCR è¯†åˆ«
    step.value = 1;
    processingTip.value = '正在进行发票识别,请稍候...';
    const ocrData = await ocrPurchaseInvoice(blobId);
    // æ£€æŸ¥é”™è¯¯ï¼šrawText ä¸ºé”™è¯¯æç¤ºï¼Œä¸”业务字段全空
    const hasData = !!(
      ocrData.invoiceNo ||
      ocrData.invoiceTitle ||
      ocrData.supplierName ||
      ocrData.price != null ||
      ocrData.invoiceTime ||
      ocrData.invoiceType ||
      ocrData.taxRate != null ||
      ocrData.remark
    );
    if (ocrData.rawText && !hasData) {
      processingTip.value = '';
      step.value = 0;
      message.error(ocrData.rawText);
      return;
    }
    if (!hasData) {
      processingTip.value = '';
      step.value = 0;
      message.warning('未识别到发票数据,请手动录入或重新上传');
      return;
    }
    ocrResult.value = ocrData;
    step.value = 2;
  } catch {
    processingTip.value = '';
    step.value = 0;
    onError(new Error('上传失败'));
  }
}
function handleConfirm() {
  if (ocrResult.value) {
    emit('success', ocrResult.value);
  }
  handleClose();
}
function handleReset() {
  step.value = 0;
  fileList.value = [];
  ocrResult.value = undefined;
  processingTip.value = '';
}
function handleClose() {
  open.value = false;
  handleReset();
}
defineExpose({ open: handleOpen });
</script>
<template>
  <Modal
    v-model:open="open"
    title="识别录入发票"
    width="680px"
    :footer="null"
    @cancel="handleClose"
  >
    <!-- Step 0: ä¸Šä¼  -->
    <template v-if="step === 0">
      <Upload.Dragger
        v-model:file-list="fileList"
        :max-count="1"
        :custom-request="handleUpload"
        accept=".pdf,.png,.jpg,.jpeg"
        @remove="handleReset"
      >
        <p class="text-4xl text-gray-400">
          <IconifyIcon icon="ant-design:inbox-outlined" />
        </p>
        <p class="text-base text-gray-500">点击或拖拽发票文件到此区域上传</p>
        <p class="text-sm text-gray-400">支持 PDF、JPG、PNG æ ¼å¼ï¼Œä¸Žæ¥ç¥¨é™„件上传格式一致</p>
      </Upload.Dragger>
    </template>
    <!-- Step 1: è¯†åˆ«ä¸­ -->
    <template v-else-if="step === 1">
      <div class="flex flex-col items-center gap-4 py-12">
        <a-spin size="large" />
        <span class="text-base text-gray-500">{{ processingTip }}</span>
      </div>
    </template>
    <!-- Step 2: é¢„览结果 -->
    <template v-else-if="step === 2 && ocrResult">
      <div class="mb-4">
        <Descriptions :column="2" bordered size="small">
          <Descriptions.Item v-if="ocrResult.invoiceNo" label="发票号码">
            {{ ocrResult.invoiceNo }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.invoiceType" label="发票类型">
            {{ ocrResult.invoiceType }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.invoiceTitle" label="发票抬头">
            {{ ocrResult.invoiceTitle }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.supplierName" label="销售方(需核对)">
            <Tag color="orange">{{ ocrResult.supplierName }}</Tag>
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.price != null" label="来票金额(元)">
            {{ ocrResult.price }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.invoiceTime" label="开票日期">
            {{ ocrResult.invoiceTime }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.taxRate != null" label="税率(%)">
            {{ ocrResult.taxRate }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.remark" label="备注" :span="2">
            {{ ocrResult.remark }}
          </Descriptions.Item>
        </Descriptions>
      </div>
      <Alert
        message="销售方名称仅为发票文本识别结果,请在表单中选择采购订单后核对订单供应商与销售方是否一致;识别数据可手动修改。"
        type="info"
        show-icon
        class="mb-4"
      />
      <div class="flex justify-end gap-2">
        <Button @click="handleReset">重新上传</Button>
        <Button type="primary" @click="handleConfirm">确认并填入表单</Button>
      </div>
    </template>
  </Modal>
</template>
src/views/erp/purchase/invoice/modules/purchase-order-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,194 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseOrderApi } from '#/api/erp/purchase/order';
import { computed, nextTick, ref, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { Input, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  getPurchaseOrder,
  getPurchaseOrderPage,
} from '#/api/erp/purchase/order';
defineOptions({ name: 'ErpPurchaseInvoiceOrderSelect' });
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    modelValue?: number;
    placeholder?: string;
  }>(),
  {
    disabled: false,
    modelValue: undefined,
    placeholder: '请选择采购订单',
  },
);
const emit = defineEmits<{
  change: [order: ErpPurchaseOrderApi.PurchaseOrder | undefined];
  'update:modelValue': [value: number | undefined];
}>();
const open = ref(false); // é€‰æ‹©å¼¹çª—是否打开
const order = ref<ErpPurchaseOrderApi.PurchaseOrder>(); // å½“前选中采购订单
const displayNo = computed(() => order.value?.no ?? '');
/** æ ¹æ®è®¢å•编号回显选择器 */
async function resolveOrderById(id: number | undefined) {
  if (!id) {
    order.value = undefined;
    return;
  }
  if (order.value?.id === id) {
    return;
  }
  order.value = await getPurchaseOrder(id);
}
watch(
  () => props.modelValue,
  (value) => {
    resolveOrderById(value);
  },
  { immediate: true },
);
/** è¡¨æ ¼é…ç½® */
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'no',
        label: '订单单号',
        component: 'Input',
        componentProps: {
          placeholder: '请输入订单单号',
          allowClear: true,
        },
      },
    ],
  },
  gridOptions: {
    columns: [
      { type: 'radio', width: 50, fixed: 'left' },
      { field: 'no', title: '订单单号', width: 200, fixed: 'left' },
      { field: 'supplierName', title: '供应商', minWidth: 120 },
      {
        field: 'orderTime',
        title: '订单时间',
        width: 160,
        formatter: 'formatDate',
      },
      {
        field: 'totalPrice',
        title: '含税金额',
        formatter: 'formatAmount2',
        minWidth: 120,
      },
      {
        field: 'remainInvoicePrice',
        title: '未开票金额',
        formatter: 'formatAmount2',
        minWidth: 120,
      },
      {
        field: 'status',
        title: '状态',
        minWidth: 100,
        cellRender: {
          name: 'CellDict',
          props: { type: DICT_TYPE.ERP_AUDIT_STATUS },
        },
      },
    ],
    height: 520,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getPurchaseOrderPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            status: 20, // ä»…展示已审核通过的采购订单
            remainPriceEnable: true, // ä»…展示剩余可开票金额大于 0 çš„订单(金额已开完的不展示)
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    radioConfig: {
      trigger: 'row',
      highlight: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ErpPurchaseOrderApi.PurchaseOrder>,
  gridEvents: {
    radioChange: ({ row }: { row: ErpPurchaseOrderApi.PurchaseOrder }) => {
      order.value = row;
    },
  },
});
/** æ‰“开选择弹窗 */
async function handleOpen() {
  if (props.disabled) {
    return;
  }
  open.value = true;
  await nextTick();
  gridApi.query();
}
/** ç¡®è®¤é€‰æ‹©é‡‡è´­è®¢å• */
function handleOk() {
  if (!order.value?.id) {
    message.warning('请选择采购订单');
    return;
  }
  emit('update:modelValue', order.value.id);
  emit('change', order.value);
  open.value = false;
}
</script>
<template>
  <div>
    <Input
      readonly
      :disabled="disabled"
      :placeholder="placeholder"
      :value="displayNo"
      @click="handleOpen"
    >
      <template #suffix>
        <IconifyIcon
          class="size-4 cursor-pointer"
          icon="lucide:search"
          :style="{ cursor: disabled ? 'not-allowed' : 'pointer' }"
        />
      </template>
    </Input>
    <Modal
      v-model:open="open"
      title="选择采购订单(仅已审核通过)"
      width="80%"
      @cancel.stop="open = false"
      @ok.stop="handleOk"
    >
      <Grid table-title="采购订单列表" />
    </Modal>
  </div>
</template>
src/views/erp/purchase/order/data.ts
@@ -12,7 +12,7 @@
import { getSupplierSimpleList } from '#/api/srm/supplier';
import { getSimpleUserList } from '#/api/system/user';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { getRangePickerDefaultProps } from '#/utils';
import { getRangePickerDefaultProps, withoutFixedColumns } from '#/utils';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
@@ -65,14 +65,27 @@
    },
    {
      component: 'InputNumber',
      componentProps: {
      componentProps: (values) => ({
        class: '!w-full',
        placeholder: '请输入支付订金',
        precision: 2,
        min: 0,
      },
        max: values.totalPrice ?? undefined,
      }),
      fieldName: 'depositPrice',
      label: '支付订金',
      dependencies: {
        triggerFields: ['totalPrice'],
        rules(values) {
          if (values.totalPrice != null) {
            return z
              .number()
              .min(0, '订金不能小于0')
              .max(values.totalPrice, '订金不能大于优惠后金额');
          }
          return z.number().min(0, '订金不能小于0');
        },
      },
      rules: 'required',
    },
    {
@@ -162,7 +175,7 @@
export function useFormItemColumns(
  disabled: boolean,
): VxeTableGridOptions['columns'] {
  return [
  const columns: VxeTableGridOptions['columns'] = [
    { type: 'seq', title: '序号', minWidth: 50, fixed: 'left' },
    {
      field: 'productId',
@@ -238,6 +251,8 @@
      visible: !disabled,
    },
  ];
  // æŸ¥çœ‹å®¡æ‰¹æ˜Žç»†ï¼ˆåªè¯»ï¼‰æ—¶å–消固定列,避免窄弹窗里挤掉产品名称、单位、备注等列
  return disabled ? withoutFixedColumns(columns) : columns;
}
/** åˆ—表的搜索表单 */
src/views/erp/purchase/order/detail/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,134 @@
<script lang="ts" setup>
import type { ErpPurchaseOrderApi } from '#/api/erp/purchase/order';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
} from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
import { getPurchaseOrder } from '#/api/erp/purchase/order';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
import ItemForm from '../modules/item-form.vue';
defineOptions({ name: 'ErpPurchaseOrderDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ErpPurchaseOrderApi.PurchaseOrder>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** å…¥åº“状态渲染 */
function renderInStatus(val?: number) {
  const map: Record<number, { text: string; color: string }> = {
    0: { text: '未入库', color: 'default' },
    1: { text: '部分入库', color: 'warning' },
    2: { text: '全部入库', color: 'success' },
  };
  const item = map[val ?? 0]!;
  return h(Tag, { color: item.color }, () => item.text);
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '采购订单号', field: 'no' },
    { label: '供应商名称', field: 'supplierName', render: textRender },
    {
      label: '订单时间',
      field: 'orderTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '合计数量',
      field: 'totalCount',
      render: (val) => erpCountInputFormatter(val) || '-',
    },
    {
      label: '合计金额',
      field: 'totalPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '优惠金额',
      field: 'discountPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '定金金额',
      field: 'depositPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '审批状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.ERP_AUDIT_STATUS, value: val }),
    },
    {
      label: '入库状态',
      field: 'inStatus',
      render: (val) => renderInStatus(val as number),
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getPurchaseOrder(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- é‡‡è´­æ˜Žç»† -->
        <div
          v-if="formData?.items?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">采购明细</span>
          </div>
          <ItemForm :items="formData.items" :disabled="true" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/erp/purchase/order/index.vue
@@ -16,12 +16,12 @@
  deletePurchaseOrder,
  exportPurchaseOrder,
  getPurchaseOrderPage,
  updatePurchaseOrderStatus,
} from '#/api/erp/purchase/order';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
/** ERP é‡‡è´­è®¢å•列表 */
defineOptions({ name: 'ErpPurchaseOrder' });
@@ -30,6 +30,11 @@
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -42,6 +47,11 @@
async function handleExport() {
  const data = await exportPurchaseOrder(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '采购订单.xls', source: data });
}
/** å¯¼å…¥é‡‡è´­è®¢å• */
function handleImport() {
  importModalApi.open();
}
/** æ–°å¢žé‡‡è´­è®¢å• */
@@ -63,24 +73,6 @@
  try {
    await deletePurchaseOrder(ids);
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å®¡æ‰¹/反审批操作 */
async function handleUpdateStatus(
  row: ErpPurchaseOrderApi.PurchaseOrder,
  status: number,
) {
  const hideLoading = message.loading({
    content: `确定${status === 20 ? '审批' : '反审批'}该订单吗?`,
    duration: 0,
  });
  try {
    await updatePurchaseOrderStatus(row.id!, status);
    message.success(`${status === 20 ? '审批' : '反审批'}成功`);
    handleRefresh();
  } finally {
    hideLoading();
@@ -159,7 +151,9 @@
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="采购订单列表">
      <template #toolbar-tools>
        <TableAction
@@ -177,6 +171,13 @@
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['erp:purchase-order:export'],
              onClick: handleExport,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['erp:purchase-order:import'],
              onClick: handleImport,
            },
            {
              label: '批量删除',
@@ -215,20 +216,6 @@
              auth: ['erp:purchase-order:update'],
              ifShow: () => row.status !== 20,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: row.status === 10 ? '审批' : '反审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['erp:purchase-order:update-status'],
              popConfirm: {
                title: `确认${row.status === 10 ? '审批' : '反审批'}${row.no}吗?`,
                confirm: handleUpdateStatus.bind(
                  null,
                  row,
                  row.status === 10 ? 20 : 10,
                ),
              },
            },
            {
              label: '确认收货',
src/views/erp/purchase/order/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,167 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { ImportResult } from '#/api/erp/purchase/order';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Switch, Upload } from 'ant-design-vue';
import {
  importPurchaseOrder,
  importPurchaseOrderTemplate,
} from '#/api/erp/purchase/order';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
/** æ˜¯å¦å°†å¯¼å…¥çš„订单直接置为“已审核” */
const directApprove = ref(false);
const importResult = ref<ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importPurchaseOrder(
        fileList.value[0] as File,
        directApprove.value,
      );
      // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
      if (failedCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importPurchaseOrderTemplate();
  downloadFileFromBlobPart({ fileName: '采购订单导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createList?.length ?? 0);
const updateCount = computed(() => importResult.value?.updateList?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureList ?? {}).length,
);
</script>
<template>
  <Modal title="导入采购订单" class="w-[720px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-4">
        <span class="mr-2">是否审核</span>
        <Switch v-model:checked="directApprove" />
      </div>
      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›æ¨¡æ¿ä¸­â€œä¾›åº”商名称”按名称(或编码)填写,需与系统内供应商一致;“产品编码”按物料编码填写(“产品名称”列仅供对照,匹配以产品编码为准);“采购时间”格式如 2026-01-01 10:00:00,留空取当前时间。</p>
        <p>一张采购单可含多条明细:相同“采购单编号”的多行会合并为一张订单(主表字段取该组首行);“采购单编号”留空时每一行各生成一张新订单。采购单编号已存在时会导入失败。</p>
        <p>订单状态:勾选上方开关 = ç›´æŽ¥ç½®ä¸ºâ€œå·²å®¡æ ¸â€ï¼›ä¸å‹¾é€‰ = â€œæœªå®¡æ ¸â€ã€‚导入不会自动提交审批流程。</p>
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">正在导入采购订单数据,请稍候...</div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} å¼ </span>
          <span class="text-blue-600">覆盖更新:{{ updateCount }} å¼ </span>
          <span class="text-red-600">失败:{{ failureCount }} å¼ </span>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureList"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/erp/purchase/request/data.ts
@@ -271,8 +271,8 @@
        options: [
          { label: '未提交', value: 0 },
          { label: '审批中', value: 10 },
          { label: '审核通过', value: 20 },
          { label: '审核不通过', value: 30 },
          { label: '审批通过', value: 20 },
          { label: '审批不通过', value: 30 },
          { label: '已取消', value: 40 },
        ],
        placeholder: '请选择状态',
src/views/erp/purchase/request/detail/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,181 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseRequestApi } from '#/api/erp/purchase/request';
import type { DescriptionItemSchema } from '#/components/description';
import { h, nextTick, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
} from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPurchaseRequest } from '#/api/erp/purchase/request';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'ErpPurchaseRequestDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ErpPurchaseRequestApi.PurchaseRequest>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** é‡‡è´­ç”³è¯·æ˜Žç»†ï¼ˆå®¡æ‰¹è¯¦æƒ…只读列) */
function useDetailItemColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', width: 60 },
    { field: 'productName', title: '产品名称', minWidth: 180 },
    { field: 'productUnitName', title: '单位', minWidth: 80 },
    {
      field: 'count',
      title: '数量',
      minWidth: 100,
      formatter: ({ cellValue }) => erpCountInputFormatter(cellValue) || '-',
    },
    {
      field: 'productPrice',
      title: '单价(参考价)',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'totalPrice',
      title: '预估金额',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'taxPercent',
      title: '税率(%)',
      minWidth: 90,
      formatter: ({ cellValue }) => (cellValue ? `${cellValue}%` : '-'),
    },
    { field: 'demandTime', title: '需求日期', minWidth: 110 },
    {
      field: 'remark',
      title: '备注',
      minWidth: 160,
      showOverflow: 'tooltip',
    },
    {
      field: 'qcCheckFlag',
      title: '是否质检',
      minWidth: 80,
      formatter: ({ cellValue }) => (cellValue ? '是' : '否'),
    },
  ];
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useDetailItemColumns(),
    data: [],
    minHeight: 200,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'seq',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  },
});
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '申请编号', field: 'no' },
    {
      label: '审批状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.ERP_AUDIT_STATUS, value: val }),
    },
    { label: '申请人', field: 'requestUserName', render: textRender },
    { label: '申请部门', field: 'requestDeptName', render: textRender },
    {
      label: '申请时间',
      field: 'requestTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '供应商名称', field: 'supplierName', render: textRender },
    {
      label: '合计数量',
      field: 'totalCount',
      render: (val) => erpCountInputFormatter(val) || '-',
    },
    {
      label: '合计金额',
      field: 'totalPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    { label: '申请理由', field: 'requestReason', render: textRender },
    { label: '生成采购订单', field: 'orderNo', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getPurchaseRequest(Number(props.id));
    if (formData.value?.items?.length) {
      await nextTick();
      await gridApi.grid.reloadData(formData.value.items);
    }
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- ç”³è¯·æ˜Žç»† -->
        <div
          v-if="formData?.items?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">申请明细</span>
          </div>
          <Grid />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/erp/purchase/request/index.vue
@@ -17,11 +17,13 @@
  getApproveProcessList,
  getPurchaseRequestPage,
  submitPurchaseRequest,
  syncPurchaseRequestStatus,
} from '#/api/erp/purchase/request';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
import OrderForm from './modules/order-form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
@@ -32,6 +34,11 @@
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -54,6 +61,11 @@
async function handleExport() {
  const data = await exportPurchaseRequest(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '采购申请.xls', source: data });
}
/** å¯¼å…¥é‡‡è´­ç”³è¯· */
function handleImport() {
  importModalApi.open();
}
/** æ–°å¢žé‡‡è´­ç”³è¯· */
@@ -145,11 +157,18 @@
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getPurchaseRequestPage({
          const result = await getPurchaseRequestPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
          const processingItems = (result.list || []).filter(
            (item) => item.status === 10 && item.processInstanceId,
          );
          if (processingItems.length > 0) {
            await syncPurchaseRequestStatus(processingItems);
          }
          return result;
        },
      },
    },
@@ -172,6 +191,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <OrderFormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="采购申请列表">
@@ -193,6 +213,13 @@
              onClick: handleExport,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['erp:purchase-request:import'],
              onClick: handleImport,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
@@ -210,8 +237,8 @@
      <template #status="{ row }">
        <Tag v-if="row.status === 0" color="default">未提交</Tag>
        <Tag v-else-if="row.status === 10" color="processing">审批中</Tag>
        <Tag v-else-if="row.status === 20" color="success">审核通过</Tag>
        <Tag v-else-if="row.status === 30" color="error">审核不通过</Tag>
        <Tag v-else-if="row.status === 20" color="success">审批通过</Tag>
        <Tag v-else-if="row.status === 30" color="error">审批不通过</Tag>
        <Tag v-else-if="row.status === 40" color="warning">已取消</Tag>
      </template>
      <template #inStatus="{ row }">
@@ -234,7 +261,7 @@
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['erp:purchase-request:update'],
              ifShow: () => row.status === 0,
              ifShow: () => row.status === 0 || Number(row.status) === 30,
              onClick: handleEdit.bind(null, row),
            },
            {
src/views/erp/purchase/request/modules/form.vue
@@ -13,6 +13,8 @@
import {
  createPurchaseRequest,
  getPurchaseRequest,
  resetPurchaseRequestStatus,
  syncPurchaseRequestStatus,
  updatePurchaseRequest,
} from '#/api/erp/purchase/request';
import { $t } from '#/locales';
@@ -88,6 +90,13 @@
      remark: item.remark,
    }));
    try {
      console.log('[DEBUG] formType:', formType.value, 'status:', formData.value?.status, 'type:', typeof formData.value?.status);
      if (formType.value === 'edit' && Number(formData.value?.status) !== 0) {
        if (Number(formData.value?.status) !== 30) {
          await syncPurchaseRequestStatus([formData.value!]);
        }
        await resetPurchaseRequestStatus(formData.value!.id!);
      }
      await (formType.value === 'create'
        ? createPurchaseRequest(data)
        : updatePurchaseRequest(data));
src/views/erp/purchase/request/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { ImportResult } from '#/api/erp/purchase/request';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Upload } from 'ant-design-vue';
import {
  importPurchaseRequest,
  importPurchaseRequestTemplate,
} from '#/api/erp/purchase/request';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const importResult = ref<ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importPurchaseRequest(fileList.value[0] as File);
      // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
      if (failedCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importPurchaseRequestTemplate();
  downloadFileFromBlobPart({ fileName: '采购申请导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createList?.length ?? 0);
const updateCount = computed(() => importResult.value?.updateList?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureList ?? {}).length,
);
</script>
<template>
  <Modal title="导入采购申请" class="w-[720px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:产品编码、数量。“产品编码”按物料编码填写(“产品名称”列仅供对照),供应商可空(填了需与系统供应商一致),“需求日期”格式如 2026-01-01,“是否来料检验”填“是/否”。</p>
        <p>一张采购申请可含多条明细:相同“采购申请单号”的多行会合并为一张申请(主表字段取该组首行);“采购申请单号”留空时每一行各生成一张新申请。采购申请单号已存在时会导入失败。</p>
        <p>导入的申请,其「申请人 / ç”³è¯·éƒ¨é—¨ã€æŒ‰å½“前登录用户自动带出;状态为“未提交”,可在列表中继续编辑或提交审批。</p>
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">正在导入采购申请数据,请稍候...</div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} æ¡</span>
          <span class="text-blue-600">覆盖更新:{{ updateCount }} æ¡</span>
          <span class="text-red-600">失败:{{ failureCount }} æ¡</span>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureList"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/erp/sale/order/data.ts
@@ -7,7 +7,7 @@
import { getAccountSimpleList } from '#/api/erp/finance/account';
import { getItemPage } from '#/api/mdm/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getRangePickerDefaultProps, withoutFixedColumns } from '#/utils';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { markRaw } from 'vue';
@@ -181,14 +181,27 @@
    },
    {
      component: 'InputNumber',
      componentProps: {
      componentProps: (values) => ({
        class: '!w-full',
        placeholder: '请输入收取订金',
        precision: 2,
        min: 0,
      },
        max: values.totalPrice ?? undefined,
      }),
      fieldName: 'depositPrice',
      label: '收取订金',
      dependencies: {
        triggerFields: ['totalPrice'],
        rules(values) {
          if (values.totalPrice != null) {
            return z
              .number()
              .min(0, '订金不能小于0')
              .max(values.totalPrice, '收取订金不能大于优惠后金额');
          }
          return z.number().min(0, '订金不能小于0').optional();
        },
      },
      rules: z.number().min(0).optional(),
    },
  ];
@@ -198,7 +211,7 @@
export function useFormItemColumns(
  disabled: boolean,
): VxeTableGridOptions['columns'] {
  return [
  const columns: VxeTableGridOptions['columns'] = [
    { type: 'seq', title: '序号', minWidth: 50, fixed: 'left' },
    {
      field: 'productId',
@@ -298,6 +311,8 @@
      visible: !disabled,
    },
  ];
  // æŸ¥çœ‹å®¡æ‰¹æ˜Žç»†ï¼ˆåªè¯»ï¼‰æ—¶å–消固定列,避免窄弹窗里挤掉产品名称、单位、备注等列
  return disabled ? withoutFixedColumns(columns) : columns;
}
/** åˆ—表的搜索表单 */
@@ -365,8 +380,10 @@
      component: 'Select',
      componentProps: {
        options: [
          { label: '未审核', value: 10 },
          { label: '未提交', value: 0 },
          { label: '审批中', value: 15 },
          { label: '已审核', value: 20 },
          { label: '已驳回', value: 35 },
          { label: '已作废', value: 30 },
        ],
        placeholder: '请选择状态',
src/views/erp/sale/order/detail/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,154 @@
<script lang="ts" setup>
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
} from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
import { getSaleOrder } from '#/api/erp/sale/order';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
import ItemForm from '../modules/item-form.vue';
defineOptions({ name: 'ErpSaleOrderDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ErpSaleOrderApi.SaleOrder>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** å‡ºåº“状态渲染 */
function renderOutStatus(val?: number) {
  const map: Record<number, { text: string; color: string }> = {
    0: { text: '未出库', color: 'default' },
    1: { text: '已预留', color: 'processing' },
    2: { text: '部分出库', color: 'warning' },
    3: { text: '全部出库', color: 'success' },
  };
  const item = map[val ?? 0]!;
  return h(Tag, { color: item.color }, () => item.text);
}
/** ç”Ÿäº§çŠ¶æ€æ¸²æŸ“ */
function renderProductionStatus(val?: number) {
  if (formData.value?.needProduction !== 1) {
    return h(Tag, { color: 'default' }, () => '无需生产');
  }
  const map: Record<number, { text: string; color: string }> = {
    0: { text: '未完成', color: 'warning' },
    1: { text: '已完成', color: 'success' },
  };
  const item = map[val ?? 0]!;
  return h(Tag, { color: item.color }, () => item.text);
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '订单单号', field: 'no', render: textRender },
    { label: '客户名称', field: 'customerName', render: textRender },
    { label: '关联合同', field: 'contractNo', render: textRender },
    {
      label: '订单时间',
      field: 'orderTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '合计数量',
      field: 'totalCount',
      render: (val) => erpCountInputFormatter(val) || '-',
    },
    {
      label: '合计金额',
      field: 'totalPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '优惠金额',
      field: 'discountPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '定金金额',
      field: 'depositPrice',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '审批状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.ERP_AUDIT_STATUS, value: val }),
    },
    {
      label: '出库状态',
      field: 'outStatus',
      render: (val) => renderOutStatus(val as number),
    },
    {
      label: '生产状态',
      field: 'productionStatus',
      render: (val) => renderProductionStatus(val as number),
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getSaleOrder(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- é”€å”®æ˜Žç»† -->
        <div
          v-if="formData?.items?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">销售明细</span>
          </div>
          <ItemForm :items="formData.items" :disabled="true" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/erp/sale/order/index.vue
@@ -16,14 +16,17 @@
  deleteSaleOrder,
  enableSaleOrder,
  exportSaleOrder,
  getSaleOrderApproveProcessList,
  getSaleOrderPage,
  updateSaleOrderStatus,
  submitSaleOrder,
} from '#/api/erp/sale/order';
import { generateFromSaleOrder } from '#/api/mes/wm/salesnotice';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
import SaleOrderPrint from './modules/print.vue';
import SalesNoticeForm from '#/views/wls/salesnotice/modules/form.vue';
/** ERP é”€å”®è®¢å•列表 */
@@ -34,6 +37,11 @@
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -51,6 +59,11 @@
async function handleExport() {
  const data = await exportSaleOrder(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '销售订单.xls', source: data });
}
/** å¯¼å…¥é”€å”®è®¢å• */
function handleImport() {
  importModalApi.open();
}
/** æ–°å¢žé”€å”®è®¢å• */
@@ -78,21 +91,34 @@
  }
}
/** å®¡æ‰¹/反审批操作 */
async function handleUpdateStatus(
  row: ErpSaleOrderApi.SaleOrder,
  status: number,
) {
/** æäº¤é”€å”®è®¢å•审批(协同办公) */
async function handleSubmit(row: ErpSaleOrderApi.SaleOrder) {
  // èŽ·å–å¯ç”¨çš„å®¡æ‰¹æµç¨‹å®šä¹‰
  const processList = await getSaleOrderApproveProcessList();
  if (isEmpty(processList)) {
    message.error('销售订单审批流程未配置,请先在「协同办公→流程管理」中创建并发布销售订单审批流程');
    return;
  }
  const hideLoading = message.loading({
    content: `确定${status === 20 ? '审批' : '反审批'}该订单吗?`,
    content: '正在提交审批...',
    duration: 0,
  });
  try {
    await updateSaleOrderStatus(row.id!, status);
    message.success(`${status === 20 ? '审批' : '反审批'}成功`);
    await submitSaleOrder(row.id!, processList[0].key);
    message.success('提交审批成功,请到协同办公中处理');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¿›åº¦ï¼ˆè·³è½¬ååŒåŠžå…¬æµç¨‹è¯¦æƒ…ï¼Œåªè¯»æ¨¡å¼ï¼Œä¸æ˜¾ç¤ºå®¡æ‰¹æ“ä½œï¼‰ */
function handleViewApproval(row: ErpSaleOrderApi.SaleOrder) {
  if (row.processInstanceId) {
    router.push({
      name: 'BpmProcessInstanceDetail',
      query: { id: row.processInstanceId, viewOnly: '1' },
    });
  }
}
@@ -138,6 +164,12 @@
/** æŸ¥çœ‹è¯¦æƒ… */
function handleDetail(row: ErpSaleOrderApi.SaleOrder) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** æ‰“印/导出销售订单详情,供企业盖章留存 */
const printRef = ref<InstanceType<typeof SaleOrderPrint>>();
function handlePrint(row: ErpSaleOrderApi.SaleOrder) {
  printRef.value?.print(row.id!);
}
/** æŸ¥çœ‹å…³è”合同 */
@@ -219,7 +251,9 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <SalesNoticeFormModal />
    <SaleOrderPrint ref="printRef" />
    <Grid table-title="销售订单列表">
      <template #toolbar-tools>
        <TableAction
@@ -237,6 +271,13 @@
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['erp:sale-order:export'],
              onClick: handleExport,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['erp:sale-order:import'],
              onClick: handleImport,
            },
            {
              label: '批量删除',
@@ -270,8 +311,10 @@
        <span v-else>-</span>
      </template>
      <template #status="{ row }">
        <Tag v-if="row.status === 10" color="warning">未审核</Tag>
        <Tag v-if="row.status === 0" color="default">未提交</Tag>
        <Tag v-if="row.status === 15" color="processing">审批中</Tag>
        <Tag v-if="row.status === 20" color="success">已审核</Tag>
        <Tag v-if="row.status === 35" color="warning">已驳回</Tag>
        <Tag v-if="row.status === 30" color="error">已作废</Tag>
      </template>
      <template #needProduction="{ row }">
@@ -304,34 +347,38 @@
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '打印',
              type: 'link',
              icon: 'lucide:printer',
              auth: ['erp:sale-order:query'],
              onClick: handlePrint.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['erp:sale-order:update'],
              ifShow: () => row.status === 10,
              ifShow: () => row.status === 0 || row.status === 35,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '审批',
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['erp:sale-order:update-status'],
              ifShow: () => row.status === 10,
              ifShow: () => row.status === 0 || row.status === 35,
              popConfirm: {
                title: `确认审批${row.no}吗?`,
                confirm: handleUpdateStatus.bind(null, row, 20),
                title: `确认提交${row.no}的审批吗?提交后审批将在协同办公中完成`,
                confirm: handleSubmit.bind(null, row),
              },
            },
            {
              label: '反审批',
              label: '审批进度',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['erp:sale-order:update-status'],
              ifShow: () => row.status === 20,
              popConfirm: {
                title: `确认反审批${row.no}吗?`,
                confirm: handleUpdateStatus.bind(null, row, 10),
              },
              icon: 'lucide:file-clock',
              auth: ['erp:sale-order:query'],
              ifShow: () => row.status === 15 && !!row.processInstanceId,
              onClick: handleViewApproval.bind(null, row),
            },
            {
              label: '作废',
@@ -360,7 +407,7 @@
              type: 'link',
              icon: 'ant-design:file-protect-outlined',
              auth: ['mes:wm-sales-notice:create'],
              ifShow: () => row.status === 20 && row.canCreateSalesNotice,
              ifShow: () => row.status === 20 && !!row.canCreateSalesNotice,
              onClick: handleGenerateNotice.bind(null, row),
            },
            {
@@ -369,7 +416,7 @@
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['erp:sale-order:delete'],
              ifShow: () => row.status === 10,
              ifShow: () => row.status === 0 || row.status === 35,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, [row.id!]),
src/views/erp/sale/order/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,155 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { ImportResult } from '#/api/erp/sale/order';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Upload } from 'ant-design-vue';
import { importSaleOrder, importSaleOrderTemplate } from '#/api/erp/sale/order';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const importResult = ref<ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importSaleOrder(fileList.value[0] as File);
      // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
      if (failedCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importSaleOrderTemplate();
  downloadFileFromBlobPart({ fileName: '销售订单导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createList?.length ?? 0);
const updateCount = computed(() => importResult.value?.updateList?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureList ?? {}).length,
);
</script>
<template>
  <Modal title="导入销售订单" class="w-[720px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›æ¨¡æ¿ä¸­â€œå®¢æˆ·åç§°â€æŒ‰åç§°å¡«å†™ï¼Œéœ€ä¸Ž CRM å®¢æˆ·ä¸€è‡´ï¼›â€œäº§å“ç¼–码”按物料编码填写(“产品名称”列仅供对照,匹配以产品编码为准);“下单时间”格式如 2026-01-01 10:00:00,留空取当前时间;“单价”留空取物料销售价。</p>
        <p>一张销售单可含多条明细:相同“销售单编号”的多行会合并为一张订单(主表字段取该组首行);“销售单编号”留空时每一行各生成一张新订单。销售单编号已存在时会导入失败。</p>
        <p>导入后的订单为“未提交”状态,可在列表中继续编辑或提交审批;导入不会自动提交审批流程。</p>
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">正在导入销售订单数据,请稍候...</div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} å¼ </span>
          <span class="text-blue-600">覆盖更新:{{ updateCount }} å¼ </span>
          <span class="text-red-600">失败:{{ failureCount }} å¼ </span>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureList"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/erp/sale/order/modules/item-form.vue
@@ -63,6 +63,10 @@
    minHeight: 250,
    autoResize: true,
    border: true,
    // æ˜Žç»†åˆ—较多,启用横向滚动,避免列被压缩导致显示不完整(协同办公审批详情等窄容器场景)
    scroll: {
      x: 'auto',
    },
    rowConfig: {
      keyField: 'seq',
      isHover: true,
src/views/erp/sale/order/modules/print.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,459 @@
<script lang="ts" setup>
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import { computed, nextTick, ref } from 'vue';
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { erpPriceMultiply, formatDateTime } from '@vben/utils';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getAccountSimpleList } from '#/api/erp/finance/account';
import { getSaleOrder } from '#/api/erp/sale/order';
import { getSimpleUserList } from '#/api/system/user';
/**
 * é”€å”®è®¢å•打印组件
 *
 * ç”¨äºŽå¯¼å‡º/打印销售订单详细信息,供企业盖章留存。
 * æ‰“印样式参考 WMS å‡ºåº“单打印(wms/order/shipment/modules/print.vue)。
 */
defineOptions({ name: 'ErpSaleOrderPrint' });
const userStore = useUserStore();
const printData = ref<Partial<ErpSaleOrderApi.SaleOrder>>({});
/** é”€å”®äººå‘˜æ˜µç§°æ˜ å°„ */
const saleUserMap = ref<Map<number, string>>(new Map());
/** ç»“算账户名称映射 */
const accountMap = ref<Map<number, string>>(new Map());
/** å®¢æˆ·åç§°æ˜ å°„(详情接口不返回客户名称,需按客户编号解析) */
const customerMap = ref<Map<number, string>>(new Map());
/** æ‰“印时间 */
const printTime = ref('');
/** è®¢å•状态文案(status ä¸ºç©ºæ—¶ä¸è°ƒç”¨ getDictLabel,避免内部对 undefined æ‰§è¡Œ toString æŠ¥é”™ï¼‰ */
const statusText = computed(() => {
  if (printData.value.status == null) {
    return '-';
  }
  const label = getDictLabel(DICT_TYPE.ERP_AUDIT_STATUS, printData.value.status);
  return label || '-';
});
/** å‡ºåº“状态文案 */
const outStatusText = computed(() => {
  switch (printData.value.outStatus) {
    case 0:
      return '未出库';
    case 1:
      return '已预留';
    case 2:
      return '部分出库';
    case 3:
      return '全部出库';
    default:
      return '-';
  }
});
/** ç”Ÿäº§çŠ¶æ€æ–‡æ¡ˆ */
const productionStatusText = computed(() => {
  if (printData.value.needProduction !== 1) {
    return '无需生产';
  }
  return printData.value.productionStatus === 1 ? '已完成' : '未完成';
});
/** é”€å”®äººå‘˜åç§° */
const saleUserName = computed(
  () => saleUserMap.value.get(printData.value.saleUserId!) || '-',
);
/** ç»“算账户名称 */
const accountName = computed(
  () => accountMap.value.get(printData.value.accountId!) || '-',
);
/** å®¢æˆ·åç§°ï¼šä¼˜å…ˆå–详情返回的客户名称,否则按客户编号解析 */
const customerName = computed(
  () =>
    printData.value.customerName ||
    customerMap.value.get(printData.value.customerId!) ||
    '-',
);
/** åˆ›å»ºäººåç§°ï¼šè¯¦æƒ…接口不返回创建人名称,按创建人编号解析 */
const creatorName = computed(
  () =>
    printData.value.creatorName ||
    saleUserMap.value.get(Number(printData.value.creator)) ||
    '-',
);
/** å•ä»· Ã— æ•°é‡ï¼ˆä»»ä¸€ä¸ºç©ºè¿”回 undefined) */
function multiplyPrice(price?: number, count?: number) {
  return price == null || count == null ? undefined : erpPriceMultiply(price, count);
}
/**
 * æ˜Žç»†è¡Œï¼ˆåŽç«¯ä¸è¿”回明细行的 totalProductPrice/totalPrice,需按单价×数量计算:
 * é‡‘额 = å•ä»· Ã— æ•°é‡ï¼›ç¨Žé¢ = é‡‘额 Ã— ç¨ŽçŽ‡ï¼›å«ç¨Žé‡‘é¢ = é‡‘额 + ç¨Žé¢ï¼‰
 */
const printRows = computed(() =>
  (printData.value.items || []).map((item) => {
    const totalProductPrice =
      item.totalProductPrice ?? multiplyPrice(item.productPrice, item.count) ?? 0;
    const taxPrice =
      item.taxPrice ?? multiplyPrice(totalProductPrice, (item.taxPercent ?? 0) / 100) ?? 0;
    const totalPrice = item.totalPrice ?? totalProductPrice + taxPrice;
    return { ...item, totalProductPrice, taxPrice, totalPrice };
  }),
);
/** æ˜Žç»†åˆè®¡ï¼šæ•°é‡/金额优先取订单头(后端权威值),税额与含税金额按明细累加(订单头 totalPrice ä¸ºä¼˜æƒ åŽé‡‘额,另在金额信息区展示) */
const summaries = computed(() => {
  const rows = printRows.value;
  const count = rows.reduce((sum, item) => sum + (item.count || 0), 0);
  const totalProductPrice = rows.reduce(
    (sum, item) => sum + (item.totalProductPrice || 0),
    0,
  );
  const taxPrice = rows.reduce((sum, item) => sum + (item.taxPrice || 0), 0);
  const totalPrice = rows.reduce((sum, item) => sum + (item.totalPrice || 0), 0);
  return {
    count: printData.value.totalCount ?? count,
    totalProductPrice: printData.value.totalProductPrice ?? totalProductPrice,
    taxPrice,
    totalPrice,
  };
});
/** ä¿ç•™ä¸¤ä½å°æ•°ï¼ˆé‡‘额展示) */
function formatPrice(value?: number | null) {
  return value === undefined || value === null || Number.isNaN(Number(value))
    ? '-'
    : Number(value).toFixed(2);
}
/** ä¿ç•™ä¸‰ä½å°æ•°ï¼ˆæ•°é‡å±•示) */
function formatCount(value?: number | null) {
  return value === undefined || value === null || Number.isNaN(Number(value))
    ? '-'
    : Number(value).toFixed(3);
}
/** ç­‰å¾…条码和打印 DOM å®Œæˆç»˜åˆ¶ï¼Œé¿å…æµè§ˆå™¨æ‰“印到旧内容 */
function waitForPaint() {
  return new Promise<void>((resolve) => {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => resolve());
    });
  });
}
/** é€€å‡ºæ‰“印模式,恢复当前页面显示 */
function removePrintMode() {
  document.body.classList.remove('erp-sale-order-printing');
}
/** æ‰“印销售订单:加载数据后只展示打印区域,再调用浏览器打印 */
async function print(id: number) {
  const [order, userList, accountList, customerList] = await Promise.all([
    getSaleOrder(id),
    getSimpleUserList(),
    getAccountSimpleList(),
    getCustomerSimpleList(),
  ]);
  printData.value = order;
  saleUserMap.value = new Map(
    userList.map((item) => [Number(item.id), item.nickname || item.username]),
  );
  accountMap.value = new Map(
    accountList.map((item) => [Number(item.id), item.name]),
  );
  customerMap.value = new Map(
    customerList.map((item) => [Number(item.id), item.name]),
  );
  printTime.value = formatDateTime(new Date());
  await nextTick();
  await waitForPaint();
  document.body.classList.add('erp-sale-order-printing');
  window.addEventListener('afterprint', removePrintMode, { once: true });
  window.print();
}
defineExpose({ print });
</script>
<template>
  <Teleport to="body">
    <div
      id="erpSaleOrderPrint"
      class="erp-sale-order-print pointer-events-none fixed left-0 top-0 z-[-1] w-full bg-white text-[#303133] opacity-0"
    >
      <div class="relative mb-2">
        <h2 class="m-0 text-center text-[1.6em] font-bold leading-[1.2]">
          é”€å”®è®¢å•
        </h2>
        <div v-if="printData.no" class="absolute right-0 top-0">
          <Barcode
            :content="printData.no"
            :display-value="false"
            :format="BarcodeFormatEnum.CODE39"
            :height="40"
            :width="180"
          />
        </div>
      </div>
      <!-- åŸºæœ¬ä¿¡æ¯ -->
      <div
        class="mb-3 grid grid-cols-3 gap-x-6 gap-y-2 border border-solid border-[#dcdfe6] p-3 text-sm leading-[1.6]"
      >
        <div>订单单号:{{ printData.no || '-' }}</div>
        <div>
          è®¢å•时间:{{
            formatDateTime(printData.orderTime) || '-'
          }}
        </div>
        <div>客户:{{ customerName }}</div>
        <div>销售人员:{{ saleUserName }}</div>
        <div>关联合同:{{ printData.contractNo || '-' }}</div>
        <div>结算账户:{{ accountName }}</div>
        <div>订单状态:{{ statusText }}</div>
        <div>出库状态:{{ outStatusText }}</div>
        <div>生产状态:{{ productionStatusText }}</div>
        <div>创建人:{{ creatorName }}</div>
        <div>
          åˆ›å»ºæ—¶é—´ï¼š{{
            formatDateTime(printData.createTime) || '-'
          }}
        </div>
        <div class="col-span-3">备注:{{ printData.remark || '-' }}</div>
      </div>
      <!-- äº§å“æ˜Žç»† -->
      <table class="w-full border-collapse text-[13px] leading-[1.5]">
        <thead>
          <tr>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold"
            >
              åºå·
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold"
            >
              äº§å“åç§°åŠè§„æ ¼
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold"
            >
              å•位
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              æ•°é‡
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              äº§å“å•ä»·(元)
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              é‡‘额(元)
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              ç¨Žçއ(%)
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              ç¨Žé¢(元)
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              å«ç¨Žé‡‘额(元)
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold"
            >
              å¤‡æ³¨
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, index) in printRows" :key="item.id || index">
            <td class="border border-solid border-[#dcdfe6] p-2 text-center">
              {{ index + 1 }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2">
              <div>{{ item.productName || '-' }}</div>
              <div v-if="item.productSpecification" class="text-xs">
                {{ item.productSpecification }}
              </div>
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-center">
              {{ item.productUnitName || '-' }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatCount(item.count) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.productPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.totalProductPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.taxPercent) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.taxPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.totalPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2">
              {{ item.remark || '-' }}
            </td>
          </tr>
          <tr v-if="printData.items && printData.items.length > 0">
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center"
            >
              åˆè®¡
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatCount(summaries.count) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatPrice(summaries.totalProductPrice) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatPrice(summaries.taxPrice) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatPrice(summaries.totalPrice) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
          </tr>
          <tr v-if="printRows.length === 0">
            <td
              class="border border-solid border-[#dcdfe6] p-2 text-center"
              colspan="10"
            >
              æš‚无明细
            </td>
          </tr>
        </tbody>
      </table>
      <!-- é‡‘额信息 -->
      <div
        class="mt-3 grid grid-cols-4 gap-x-6 gap-y-2 border border-solid border-[#dcdfe6] p-3 text-sm leading-[1.6]"
      >
        <div>优惠率(%):{{ formatPrice(printData.discountPercent) }}</div>
        <div>付款优惠(元):{{ formatPrice(printData.discountPrice) }}</div>
        <div>优惠后金额(元):{{ formatPrice(printData.totalPrice) }}</div>
        <div>收取订金(元):{{ formatPrice(printData.depositPrice) }}</div>
      </div>
      <!-- ç›–章栏 -->
      <div
        class="mt-6 grid grid-cols-2 gap-x-10 gap-y-4 border border-dashed border-[#dcdfe6] p-4 text-sm leading-[1.8]"
      >
        <div>
          å®¢æˆ·ç›–ç« /签字:<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
        <div>
          å…¬å¸ç›–ç« /签字:<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
        <div>
          æ—¥æœŸï¼š<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
        <div>
          æ—¥æœŸï¼š<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
      </div>
      <!-- æ‰“印信息 -->
      <div
        class="mt-4 flex justify-between text-xs text-[#909399]"
      >
        <div>
          æ‰“印人员:{{ userStore.userInfo?.nickname || '-' }}
        </div>
        <div>打印时间:{{ printTime || '-' }}</div>
      </div>
    </div>
  </Teleport>
</template>
<style scoped>
@page {
  margin: 8mm 10mm;
}
@media print {
  :global(body.erp-sale-order-printing) {
    padding: 0 !important;
    margin: 0 !important;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  :global(body.erp-sale-order-printing *) {
    visibility: hidden !important;
  }
  :global(body.erp-sale-order-printing .erp-sale-order-print),
  :global(body.erp-sale-order-printing .erp-sale-order-print *) {
    visibility: visible !important;
  }
  :global(body.erp-sale-order-printing .erp-sale-order-print) {
    position: absolute;
    top: 0;
    left: 0;
    z-index: auto;
    box-sizing: border-box;
    width: 100%;
    padding: 0 !important;
    margin: 0 !important;
    pointer-events: auto;
    opacity: 1;
  }
}
</style>
src/views/hrm/attendance/exception/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { HrmAttendanceExceptionApi } from '#/api/hrm/attendance/exception';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { getAttendanceException } from '#/api/hrm/attendance/exception';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'HrmAttendanceExceptionApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<HrmAttendanceExceptionApi.AttendanceException>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '员工姓名', field: 'userName', render: textRender },
    { label: '所属部门', field: 'deptName', render: textRender },
    {
      label: '异常日期',
      field: 'date',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '异常类型', field: 'exceptionTypeName', render: textRender },
    { label: '异常说明', field: 'reason', render: textRender },
    {
      label: '审批状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value: val }),
    },
    { label: '备注', field: 'remark', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getAttendanceException(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- è€ƒå‹¤å¼‚常基本信息 -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">考勤异常基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/hrm/attendance/record/data.ts
@@ -75,6 +75,7 @@
  return [
    { field: 'userName', title: '员工姓名', minWidth: 100 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    { field: 'phone', title: '手机号', width: 130 },
    { field: 'date', title: '考勤日期', width: 110 },
    { field: 'clockInTime', title: '上班打卡时间', width: 160 },
    {
src/views/hrm/attendance/record/modules/import-form.vue
@@ -23,10 +23,18 @@
    }
    modalApi.lock();
    try {
      await importAttendanceRecord(fileList.value[0] as File, updateSupport.value);
      const res: string = await importAttendanceRecord(
        fileList.value[0] as File,
        updateSupport.value,
      );
      await modalApi.close();
      emit('success');
      message.success('导入成功');
      // åŽç«¯è¿”回导入结果(含失败原因,例如“以下员工不存在...;需补充手机号...”)
      if (res && res.length > 0) {
        message[res.includes('未') || res.includes('不完整') ? 'warning' : 'success'](res);
      } else {
        message.success('导入成功');
      }
    } finally {
      modalApi.unlock();
    }
src/views/hrm/employee/components/select.vue
@@ -118,7 +118,7 @@
          <div>员工编号:{{ selectedItem.employeeNo || '-' }}</div>
          <div>员工姓名:{{ selectedItem.name || '-' }}</div>
          <div v-if="selectedItem.deptName">部门:{{ selectedItem.deptName }}</div>
          <div v-if="selectedItem.postName">岗位:{{ selectedItem.postName }}</div>
          <div v-if="selectedItem.postNames">岗位:{{ selectedItem.postNames }}</div>
        </div>
      </template>
      <Input
src/views/hrm/employee/contract/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,297 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
import { getEmployeeSimpleList } from '#/api/hrm/employee';
/** è¡¨å•类型 */
export type FormType = 'create' | 'update';
/** æ–°å¢ž/修改员工合同的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'employeeId',
      label: '员工',
      component: 'ApiSelect',
      componentProps: {
        api: getEmployeeSimpleList,
        labelField: 'name',
        valueField: 'id',
        showSearch: true,
        filterOption: (input: string, option: any) =>
          option.label?.toLowerCase().includes(input.toLowerCase()),
        placeholder: '请选择员工',
      },
      rules: 'selectRequired',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'contractType',
      label: '合同类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择合同类型',
        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TYPE, 'number'),
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'contractTermType',
      label: '期限类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择期限类型',
        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TERM_TYPE, 'number'),
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'signCompany',
      label: '签约主体',
      component: 'Input',
      componentProps: {
        placeholder: '请输入签约主体(公司全称)',
      },
    },
    {
      fieldName: 'signDate',
      label: '签订日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择签订日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'startDate',
      label: '开始日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择开始日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'endDate',
      label: '结束日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '无固定期限可不填',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'probationStartDate',
      label: '试用期开始',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择试用期开始日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'probationEndDate',
      label: '试用期结束',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择试用期结束日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'probationSalary',
      label: '试用期工资',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入试用期工资',
        min: 0,
        precision: 2,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'regularSalary',
      label: '转正工资',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入转正工资',
        min: 0,
        precision: 2,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'blobIds',
      label: '附件',
      component: 'FileUpload',
      formItemClass: 'col-span-2',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 2,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'contractNo',
      label: '合同编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入合同编号',
      },
    },
    {
      fieldName: 'employeeId',
      label: '员工',
      component: 'ApiSelect',
      componentProps: {
        api: getEmployeeSimpleList,
        labelField: 'name',
        valueField: 'id',
        showSearch: true,
        filterOption: (input: string, option: any) =>
          option.label?.toLowerCase().includes(input.toLowerCase()),
        allowClear: true,
        placeholder: '请选择员工',
      },
    },
    {
      fieldName: 'contractType',
      label: '合同类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TYPE, 'number'),
        placeholder: '请选择合同类型',
      },
    },
    {
      fieldName: 'contractTermType',
      label: '期限类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TERM_TYPE, 'number'),
        placeholder: '请选择期限类型',
      },
    },
    {
      fieldName: 'terminateStatus',
      label: '解除终止状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TERMINATE_STATUS, 'number'),
        placeholder: '请选择解除终止状态',
      },
    },
    {
      fieldName: 'status',
      label: '合同状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_STATUS, 'number'),
        placeholder: '请选择合同状态',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmEmployeeContractApi.EmployeeContract>['columns'] {
  return [
    { field: 'contractNo', title: '合同编号', minWidth: 160 },
    {
      field: 'parentNo',
      title: '续签自',
      minWidth: 140,
      slots: { default: 'parentNo' },
    },
    { field: 'employeeName', title: '员工姓名', minWidth: 100 },
    { field: 'employeeNo', title: '员工工号', minWidth: 130 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    {
      field: 'contractType',
      title: '合同类型',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.HRM_CONTRACT_TYPE },
      },
    },
    {
      field: 'contractTermType',
      title: '期限类型',
      width: 130,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.HRM_CONTRACT_TERM_TYPE },
      },
    },
    { field: 'signDate', title: '签订日期', width: 110 },
    { field: 'startDate', title: '开始日期', width: 110 },
    { field: 'endDate', title: '结束日期', width: 110 },
    {
      field: 'status',
      title: '合同状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.HRM_CONTRACT_STATUS },
      },
    },
    {
      field: 'isCurrent',
      title: '当前合同',
      width: 90,
      slots: { default: 'isCurrent' },
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 260,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/hrm/employee/contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteEmployeeContract,
  getEmployeeContractPage,
} from '#/api/hrm/employee/contract';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
import Terminate from './modules/terminate.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
const [TerminateModal, terminateModalApi] = useVbenModal({
  connectedComponent: Terminate,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žåˆåŒ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** å¯¼å…¥åˆåŒ */
function handleImport() {
  importModalApi.open();
}
/** ç¼–辑合同 */
function handleEdit(row: HrmEmployeeContractApi.EmployeeContract) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
}
/** ç»­ç­¾åˆåŒï¼šå¸¦å…¥ä¸Šä»½åˆåŒåŸºç¡€ä¿¡æ¯ï¼Œåˆ›å»ºæ–°åˆåŒï¼ˆåŽç«¯è‡ªåŠ¨å…³è” parentId) */
function handleRenew(row: HrmEmployeeContractApi.EmployeeContract) {
  formModalApi.setData({ formType: 'create', renewFrom: row }).open();
}
/** è§£é™¤/终止合同 */
function handleTerminate(row: HrmEmployeeContractApi.EmployeeContract) {
  terminateModalApi.setData({ id: row.id }).open();
}
/** åˆ é™¤åˆåŒ */
async function handleDelete(row: HrmEmployeeContractApi.EmployeeContract) {
  await deleteEmployeeContract(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['员工合同']));
  handleRefresh();
}
/** æ˜¯å¦å¯è§£é™¤/终止:仅未解除/未终止的合同可操作 */
function isTerminable(row: HrmEmployeeContractApi.EmployeeContract) {
  return row.terminateStatus === 0;
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getEmployeeContractPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmEmployeeContractApi.EmployeeContract>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <TerminateModal @success="handleRefresh" />
    <Grid table-title="员工合同列表">
      <template #parentNo="{ row }">
        <span>{{ row.parentNo || '-' }}</span>
      </template>
      <template #isCurrent="{ row }">
        <Tag v-if="row.isCurrent" color="processing">当前</Tag>
        <span v-else>-</span>
      </template>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['员工合同']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:employee-contract:create'],
              onClick: handleCreate,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['hrm:employee-contract:import'],
              onClick: handleImport,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: 'ç»­ç­¾',
              type: 'link',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:employee-contract:create'],
              ifShow: isTerminable(row),
              onClick: handleRenew.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['hrm:employee-contract:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '解除/终止',
              type: 'link',
              icon: ACTION_ICON.CLOSE,
              auth: ['hrm:employee-contract:update'],
              ifShow: isTerminable(row),
              onClick: handleTerminate.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['hrm:employee-contract:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', ['员工合同']),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/employee/contract/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,126 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createEmployeeContract,
  getEmployeeContract,
  updateEmployeeContract,
} from '#/api/hrm/employee/contract';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const isRenew = ref(false);
const getTitle = computed(() => {
  if (formType.value === 'update') {
    return '修改员工合同';
  }
  return isRenew.value ? '续签员工合同' : '新增员工合同';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data =
      (await formApi.getValues()) as HrmEmployeeContractApi.EmployeeContract;
    try {
      // FileUpload ç»„件的值需转换为 blobId æ•°ç»„提交
      data.blobIds = (data.blobIds ?? []).map((item: any) => item.id ?? item);
      if (data.id) {
        await updateEmployeeContract(data);
      } else {
        await createEmployeeContract(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{
      formType: FormType;
      id?: number;
      renewFrom?: HrmEmployeeContractApi.EmployeeContract;
    }>();
    formType.value = data.formType;
    isRenew.value = !data?.id && !!data?.renewFrom;
    // ç»­ç­¾ï¼šå¸¦å…¥ä¸Šä»½åˆåŒçš„基础信息(员工、类型、签约主体、工资、续签来源),日期需重新填写
    if (data?.renewFrom) {
      await formApi.resetForm();
      await formApi.setValues({
        parentId: data.renewFrom.id,
        employeeId: data.renewFrom.employeeId,
        contractType: data.renewFrom.contractType,
        contractTermType: data.renewFrom.contractTermType,
        signCompany: data.renewFrom.signCompany,
        probationSalary: data.renewFrom.probationSalary,
        regularSalary: data.renewFrom.regularSalary,
      });
      return;
    }
    if (!data?.id) {
      await formApi.resetForm();
      return;
    }
    modalApi.lock();
    try {
      const detail = await getEmployeeContract(data.id);
      await formApi.setValues(detail);
      if (detail.attachmentList?.length) {
        await formApi.setFieldValue(
          'blobIds',
          detail.attachmentList.map((item) => ({
            uid: String(item.id),
            name: item.name || '',
            url: item.url || '',
            status: 'done',
            id: item.id,
          })),
        );
      }
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-[720px]">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/hrm/employee/contract/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,165 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Switch, Upload } from 'ant-design-vue';
import {
  importEmployeeContract,
  importEmployeeContractTemplate,
} from '#/api/hrm/employee/contract';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const updateSupport = ref(false);
const importResult = ref<HrmEmployeeContractApi.ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importEmployeeContract(
        fileList.value[0] as File,
        updateSupport.value,
      );
      // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(importResult.value?.failureNames ?? {}).length;
      if (failedCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} æ¡ï¼Œè¦†ç›– ${updateCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importEmployeeContractTemplate();
  downloadFileFromBlobPart({ fileName: '员工合同导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createNames?.length ?? 0);
const updateCount = computed(() => importResult.value?.updateNames?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureNames ?? {}).length,
);
</script>
<template>
  <Modal title="导入员工合同" class="w-[720px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-4">
        <span class="mr-2">同员工、同合同类型、同开始日期的合同已存在时覆盖更新该行:</span>
        <Switch v-model:checked="updateSupport" />
      </div>
      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›æ¨¡æ¿ä¸­â€œå‘˜å·¥å§“名/合同类型/合同期限类型”按名称或中文填写(合同类型如:劳动合同、劳务合同)。</p>
        <p>导入判定:按“员工姓名 + åˆåŒç±»åž‹ + åˆåŒå¼€å§‹æ—¥æœŸâ€è¯†åˆ«ã€‚未勾选“已存在时更新”时,命中已存在合同的行会导入失败并提示;勾选后则覆盖更新该行(保留原合同编号),未命中的行一律新增。</p>
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">正在导入员工合同数据,请稍候...</div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} ä»½</span>
          <span class="text-blue-600">覆盖更新:{{ updateCount }} ä»½</span>
          <span class="text-red-600">失败:{{ failureCount }} ä»½</span>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureNames"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/hrm/employee/contract/modules/terminate.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,103 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import { ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { terminateEmployeeContract } from '#/api/hrm/employee/contract';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const contractId = ref<number>();
/** è§£é™¤/终止表单 */
const schema: VbenFormSchema[] = [
  {
    fieldName: 'terminateStatus',
    label: '操作类型',
    component: 'RadioGroup',
    componentProps: {
      options: [
        { label: '解除', value: 1 },
        { label: '终止', value: 2 },
      ],
    },
    rules: 'required',
  },
  {
    fieldName: 'terminateDate',
    label: '解除/终止日期',
    component: 'DatePicker',
    componentProps: {
      placeholder: '请选择日期',
      valueFormat: 'YYYY-MM-DD',
      style: { width: '100%' },
    },
    rules: 'required',
  },
  {
    fieldName: 'terminateReason',
    label: '原因',
    component: 'Textarea',
    formItemClass: 'col-span-2',
    componentProps: {
      placeholder: '请输入解除/终止原因',
      rows: 3,
      maxlength: 255,
      showCount: true,
    },
    rules: 'required',
  },
];
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 110,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema,
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = await formApi.getValues();
    try {
      await terminateEmployeeContract({ id: contractId.value!, ...data });
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ id: number }>();
    contractId.value = data.id;
    await formApi.resetForm();
  },
});
</script>
<template>
  <Modal title="解除/终止合同" class="w-[520px]">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/hrm/employee/data.ts
@@ -10,6 +10,7 @@
import { Button } from 'ant-design-vue';
import { z } from '#/adapter/form';
import { getSalaryStructureList } from '#/api/hrm/salary/structure';
import { getDeptList } from '#/api/system/dept';
import { getSimplePostList } from '#/api/system/post';
import { getSimpleRoleList } from '#/api/system/role';
@@ -94,14 +95,15 @@
      rules: 'selectRequired',
    },
    {
      fieldName: 'postId',
      fieldName: 'postIds',
      label: '岗位',
      component: 'ApiSelect',
      componentProps: {
        api: getSimplePostList,
        labelField: 'name',
        valueField: 'id',
        placeholder: '请选择岗位',
        mode: 'multiple',
        placeholder: '请选择岗位(可多选)',
      },
      rules: 'selectRequired',
    },
@@ -177,6 +179,49 @@
      },
    },
    {
      fieldName: 'nation',
      label: '民族',
      component: 'Input',
      componentProps: {
        placeholder: '请输入民族',
      },
    },
    {
      fieldName: 'marriageStatus',
      label: '婚姻状态',
      component: 'Input',
      componentProps: {
        placeholder: '请输入婚姻状态',
      },
    },
    {
      fieldName: 'age',
      label: '年龄',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入年龄',
        min: 0,
        max: 150,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'education',
      label: '学历',
      component: 'Input',
      componentProps: {
        placeholder: '请输入学历',
      },
    },
    {
      fieldName: 'politicalStatus',
      label: '政治面貌',
      component: 'Input',
      componentProps: {
        placeholder: '请输入政治面貌',
      },
    },
    {
      fieldName: 'annualLeaveBalance',
      label: '年假余额',
      component: 'InputNumber',
@@ -197,6 +242,19 @@
        precision: 1,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'salaryStructureId',
      label: '薪酬结构',
      component: 'ApiSelect',
      componentProps: {
        api: () => getSalaryStructureList({ status: 0 }),
        labelField: 'name',
        valueField: 'id',
        allowClear: true,
        placeholder: '请选择薪酬结构',
      },
      help: '决定工作日加班倍率;未选择时使用系统默认 1.5 å€',
    },
    {
      fieldName: 'baseSalary',
@@ -310,7 +368,10 @@
    },
    { field: 'phone', title: '手机号码', minWidth: 120 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    { field: 'postName', title: '岗位', minWidth: 120 },
    { field: 'postNames', title: '岗位', minWidth: 120 },
    { field: 'nation', title: '民族', width: 90 },
    { field: 'age', title: '年龄', width: 70 },
    { field: 'education', title: '学历', width: 90 },
    {
      field: 'employeeStatus',
      title: '员工状态',
@@ -371,7 +432,7 @@
    { field: 'employeeNo', title: '员工编号', minWidth: 120 },
    { field: 'name', title: '员工姓名', minWidth: 100 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    { field: 'postName', title: '岗位', minWidth: 120 },
    { field: 'postNames', title: '岗位', minWidth: 120 },
    { field: 'phone', title: '手机号码', minWidth: 120 },
  ];
}
src/views/hrm/employee/index.vue
@@ -5,7 +5,9 @@
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Tag } from 'ant-design-vue';
import { Button, message, Progress, Tag } from 'ant-design-vue';
import { ref } from 'vue';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictLabel } from '#/packages/effects/hooks/src';
@@ -16,9 +18,15 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -57,10 +65,55 @@
  }
}
const exporting = ref(false);
const exportPercent = ref(0);
let exportTimer: ReturnType<typeof setInterval> | undefined;
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startExportProgress() {
  exportPercent.value = 0;
  exportTimer = setInterval(() => {
    if (exportPercent.value < 90) {
      exportPercent.value = Math.min(
        90,
        exportPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopExportProgress() {
  if (exportTimer) {
    clearInterval(exportTimer);
    exportTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
/** å¯¼å‡ºå‘˜å·¥ */
async function handleExport() {
  const data = await exportEmployee(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '员工列表.xls', source: data });
  if (exporting.value) return;
  exporting.value = true;
  startExportProgress();
  try {
    const data = await exportEmployee(await gridApi.formApi.getValues());
    // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡åŽè§¦å‘下载
    stopExportProgress();
    exportPercent.value = 100;
    await delay(500);
    downloadFileFromBlobPart({ fileName: '员工列表.xls', source: data });
  } finally {
    stopExportProgress();
    exporting.value = false;
  }
}
/** å¯¼å…¥å‘˜å·¥ */
function handleImport() {
  importModalApi.open();
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -95,6 +148,7 @@
<template>
  <Page auto-content-height>
    <ImportModal @success="handleRefresh" />
    <FormModal @success="handleRefresh" />
    <Grid table-title="员工列表">
      <template #toolbar-tools>
@@ -106,6 +160,13 @@
              icon: ACTION_ICON.ADD,
              auth: ['hrm:employee:create'],
              onClick: handleCreate,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['hrm:employee:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
@@ -155,5 +216,18 @@
        />
      </template>
    </Grid>
    <!-- å¯¼å‡ºè¿›åº¦æ¡é®ç½© -->
    <div
      v-if="exporting"
      class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40"
    >
      <div class="w-80 rounded-lg bg-white p-6 shadow-lg">
        <div class="mb-3 text-center text-sm text-gray-600">
          æ­£åœ¨å¯¼å‡ºå‘˜å·¥åˆ—表...
        </div>
        <Progress :percent="exportPercent" status="active" />
      </div>
    </div>
  </Page>
</template>
src/views/hrm/employee/modules/contract-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
<script lang="ts" setup>
import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
import { ref, watch } from 'vue';
import { Table, Tag } from 'ant-design-vue';
import { getEmployeeContractPage } from '#/api/hrm/employee/contract';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictLabel } from '#/packages/effects/hooks/src';
const props = defineProps<{
  employeeId?: number;
}>();
const list = ref<HrmEmployeeContractApi.EmployeeContract[]>([]);
const loading = ref(false);
/** åˆåŒçŠ¶æ€é¢œè‰²ï¼š1-待生效 2-生效中 3-即将到期 4-已到期 5-已解除 6-已终止 */
const statusColorMap: Record<number, string> = {
  1: 'processing',
  2: 'success',
  3: 'warning',
  4: 'default',
  5: 'error',
  6: 'error',
};
/** åˆ·æ–°åˆ—表 */
async function handleRefresh() {
  if (!props.employeeId) return;
  loading.value = true;
  try {
    const res = await getEmployeeContractPage({
      pageNo: 1,
      pageSize: 100,
      employeeId: props.employeeId,
    });
    list.value = res.list;
  } finally {
    loading.value = false;
  }
}
/** ç›‘听 employeeId å˜åŒ– */
watch(
  () => props.employeeId,
  (val) => {
    if (val) {
      handleRefresh();
    }
  },
  { immediate: true },
);
const columns = [
  { title: '合同编号', dataIndex: 'contractNo', width: 150 },
  { title: '续签自', dataIndex: 'parentNo', width: 130, key: 'parentNo' },
  { title: '合同类型', dataIndex: 'contractType', width: 100, key: 'contractType' },
  { title: '期限类型', dataIndex: 'contractTermType', width: 110, key: 'contractTermType' },
  { title: '签订日期', dataIndex: 'signDate', width: 110 },
  { title: '开始日期', dataIndex: 'startDate', width: 110 },
  { title: '结束日期', dataIndex: 'endDate', width: 110 },
  { title: '合同状态', dataIndex: 'status', width: 100, key: 'status' },
  { title: '当前合同', dataIndex: 'isCurrent', width: 90, key: 'isCurrent' },
];
</script>
<template>
  <div class="contract-list">
    <Table
      :columns="columns"
      :data-source="list"
      :loading="loading"
      :pagination="false"
      size="small"
      row-key="id"
    >
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'parentNo'">
          {{ record.parentNo || '-' }}
        </template>
        <template v-else-if="column.key === 'contractType'">
          {{ getDictLabel(DICT_TYPE.HRM_CONTRACT_TYPE, record.contractType) }}
        </template>
        <template v-else-if="column.key === 'contractTermType'">
          {{ getDictLabel(DICT_TYPE.HRM_CONTRACT_TERM_TYPE, record.contractTermType) }}
        </template>
        <template v-else-if="column.key === 'status'">
          <Tag :color="statusColorMap[record.status!] || 'default'">
            {{ getDictLabel(DICT_TYPE.HRM_CONTRACT_STATUS, record.status) }}
          </Tag>
        </template>
        <template v-else-if="column.key === 'isCurrent'">
          <Tag v-if="record.isCurrent" color="processing">当前</Tag>
          <span v-else>-</span>
        </template>
      </template>
    </Table>
  </div>
</template>
<style scoped>
.contract-list {
  padding: 0;
}
</style>
src/views/hrm/employee/modules/detail.vue
@@ -12,6 +12,7 @@
import WorkHistoryList from './work-history-list.vue';
import EmergencyContactList from './emergency-contact-list.vue';
import SocialSecurityList from './social-security-list.vue';
import ContractList from './contract-list.vue';
const props = defineProps<{
  employee: HrmEmployeeApi.Employee;
@@ -48,12 +49,17 @@
      <DescriptionsItem label="员工状态">
        <Tag :color="employeeStatusColor">{{ employeeStatusLabel }}</Tag>
      </DescriptionsItem>
      <DescriptionsItem label="民族">{{ employee.nation || '-' }}</DescriptionsItem>
      <DescriptionsItem label="婚姻状态">{{ employee.marriageStatus || '-' }}</DescriptionsItem>
      <DescriptionsItem label="年龄">{{ employee.age ?? '-' }}</DescriptionsItem>
      <DescriptionsItem label="学历">{{ employee.education || '-' }}</DescriptionsItem>
      <DescriptionsItem label="政治面貌">{{ employee.politicalStatus || '-' }}</DescriptionsItem>
    </Descriptions>
    <!-- å·¥ä½œä¿¡æ¯ -->
    <Descriptions title="工作信息" bordered :column="2" class="mb-4">
      <DescriptionsItem label="归属部门">{{ employee.deptName || '-' }}</DescriptionsItem>
      <DescriptionsItem label="岗位">{{ employee.postName || '-' }}</DescriptionsItem>
      <DescriptionsItem label="岗位">{{ employee.postNames || '-' }}</DescriptionsItem>
      <DescriptionsItem label="入职日期">{{ employee.hireDate }}</DescriptionsItem>
      <DescriptionsItem label="转正日期">{{ employee.regularDate || '-' }}</DescriptionsItem>
      <DescriptionsItem label="离职日期">{{ employee.leaveDate || '-' }}</DescriptionsItem>
@@ -91,6 +97,9 @@
      <Tabs.TabPane key="socialSecurity" tab="社保公积金档案">
        <SocialSecurityList form-type="detail" :employee-id="employee.id" :user-id="employee.userId" />
      </Tabs.TabPane>
      <Tabs.TabPane key="contract" tab="员工合同">
        <ContractList :employee-id="employee.id" />
      </Tabs.TabPane>
    </Tabs>
  </div>
</template>
src/views/hrm/employee/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,161 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { HrmEmployeeApi } from '#/api/hrm/employee';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Switch, Upload } from 'ant-design-vue';
import { importEmployee, importEmployeeTemplate } from '#/api/hrm/employee';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const updateSupport = ref(false);
const importResult = ref<HrmEmployeeApi.ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importEmployee(
        fileList.value[0] as File,
        updateSupport.value,
      );
      // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(importResult.value?.failureNames ?? {}).length;
      if (failedCount > 0) {
        message.warning(
          `导入完成:成功 ${createCount.value} æ¡ã€è¦†ç›– ${updateCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importEmployeeTemplate();
  downloadFileFromBlobPart({ fileName: '员工导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createNames?.length ?? 0);
const updateCount = computed(() => importResult.value?.updateNames?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureNames ?? {}).length,
);
</script>
<template>
  <Modal title="导入员工" class="w-[640px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-4">
        <span class="mr-2">手机号已存在时更新员工信息:</span>
        <Switch v-model:checked="updateSupport" />
      </div>
      <div class="mt-2 text-gray-500">
        ä»…允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›æ¨¡æ¿ä¸­"部门名称/岗位名称/用户角色"填写名称,岗位和角色多个用逗号分隔
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">正在导入员工数据,请稍候...</div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">成功创建:{{ createCount }} äºº</span>
          <span class="text-blue-600">成功更新:{{ updateCount }} äºº</span>
          <span class="text-red-600">失败:{{ failureCount }} äºº</span>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureNames"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/hrm/leave/data.ts
@@ -2,8 +2,12 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmLeaveApi } from '#/api/hrm/leave';
import { markRaw } from 'vue';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
import { UserSelect } from '#/views/system/user/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
@@ -18,6 +22,15 @@
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'userId',
      label: '申请人',
      component: markRaw(UserSelect),
      componentProps: {
        placeholder: '请选择申请人',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'leaveType',
@@ -134,7 +147,7 @@
export function useGridColumns(): VxeTableGridOptions<HrmLeaveApi.Leave>['columns'] {
  return [
    { field: 'id', title: '申请编号', width: 80 },
    { field: 'employeeName', title: '申请人', minWidth: 100 },
    { field: 'userName', title: '申请人', minWidth: 100 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    {
      field: 'leaveType',
src/views/hrm/leave/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
<script lang="ts" setup>
import type { HrmLeaveApi } from '#/api/hrm/leave';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { getLeave } from '#/api/hrm/leave';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'HrmLeaveApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<HrmLeaveApi.Leave>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '申请人', field: 'userName', render: textRender },
    { label: '所属部门', field: 'deptName', render: textRender },
    {
      label: '请假类型',
      field: 'leaveType',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.HRM_LEAVE_TYPE, value: val }),
    },
    {
      label: '开始时间',
      field: 'startTime',
      render: (val) => formatDateTime(val) as string,
    },
    {
      label: '结束时间',
      field: 'endTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '请假时长', field: 'duration', render: textRender },
    { label: '请假事由', field: 'reason', render: textRender },
    {
      label: '审批状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value: val }),
    },
    { label: '备注', field: 'remark', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getLeave(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- è¯·å‡åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">请假基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/hrm/leave/index.vue
@@ -2,9 +2,11 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmLeaveApi } from '#/api/hrm/leave';
import { ref } from 'vue';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { message, Tag } from 'ant-design-vue';
import { message, Modal, Select, Tag } from 'ant-design-vue';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictLabel } from '#/packages/effects/hooks/src';
@@ -13,6 +15,7 @@
import {
  cancelLeave,
  deleteLeave,
  getLeaveApproveProcessDefinitionList,
  getLeavePage,
  submitLeave,
} from '#/api/hrm/leave';
@@ -61,11 +64,34 @@
  }
}
/** æäº¤è¯·å‡ç”³è¯· */
const submitModalVisible = ref(false);
const currentRow = ref<HrmLeaveApi.Leave>();
const processList = ref<HrmLeaveApi.ApproveProcess[]>([]);
const selectedProcessKey = ref<string>();
const submitLoading = ref(false);
/** æäº¤è¯·å‡ç”³è¯·ï¼ˆå…ˆé€‰æ‹©å®¡æ‰¹æµç¨‹ï¼‰ */
async function handleSubmit(row: HrmLeaveApi.Leave) {
  await submitLeave(row.id!);
  message.success('提交成功');
  handleRefresh();
  processList.value = await getLeaveApproveProcessDefinitionList();
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  submitModalVisible.value = true;
}
async function confirmSubmit() {
  if (!selectedProcessKey.value) {
    message.warning('请选择请假审批流程');
    return;
  }
  submitLoading.value = true;
  try {
    await submitLeave(currentRow.value!.id!, selectedProcessKey.value);
    message.success('提交成功');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
    submitLoading.value = false;
  }
}
/** æ’¤é”€è¯·å‡ç”³è¯· */
@@ -187,5 +213,19 @@
        />
      </template>
    </Grid>
    <Modal
      v-model:open="submitModalVisible"
      title="提交至协同办公"
      width="420px"
      :confirm-loading="submitLoading"
      @ok="confirmSubmit"
    >
      <Select
        v-model:value="selectedProcessKey"
        class="w-full"
        placeholder="请选择请假审批流程"
        :options="processList.map((item) => ({ label: item.name, value: item.key }))"
      />
    </Modal>
  </Page>
</template>
src/views/hrm/leave/modules/form.vue
@@ -4,6 +4,8 @@
import { computed, ref } from 'vue';
import { useUserStore } from '@vben/stores';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
@@ -16,6 +18,8 @@
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const userStore = useUserStore();
const isDetail = computed(() => formType.value === 'detail');
const getTitle = computed(() => {
@@ -74,6 +78,8 @@
    formApi.setDisabled(data.formType === 'detail');
    modalApi.setState({ showConfirmButton: data.formType !== 'detail' });
    if (!data?.id) {
      // æ–°å¢žæ—¶é»˜è®¤ç”³è¯·äººä¸ºå½“前登录用户,可修改
      await formApi.setFieldValue('userId', userStore.userInfo?.id);
      return;
    }
    modalApi.lock();
src/views/hrm/overtime/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,320 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmOvertimeApi } from '#/api/hrm/overtime';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
export const OVERTIME_TYPE_OPTIONS = [
  { label: '工作日加班', value: 1 },
  { label: '休息日加班', value: 2 },
  { label: '法定节假日加班', value: 3 },
];
export const RELATED_TYPE_OPTIONS = [
  { label: '项目', value: 1 },
  { label: '工单', value: 2 },
];
export function getOvertimeTypeLabel(type?: number) {
  return OVERTIME_TYPE_OPTIONS.find((item) => item.value === type)?.label || '-';
}
/** æ–°å¢ž/修改加班申请的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'employeeId',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'baseInfoDivider',
      label: '申请人基础信息',
      component: 'Divider',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'employeeName',
      label: '申请人',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '系统自动带出' },
    },
    {
      fieldName: 'employeeNo',
      label: '工号',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '系统自动带出' },
    },
    {
      fieldName: 'deptName',
      label: '所属部门',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '系统自动带出' },
    },
    {
      fieldName: 'postName',
      label: '岗位/职级',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '系统自动带出' },
    },
    {
      fieldName: 'overtimeInfoDivider',
      label: '加班核心信息',
      component: 'Divider',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'overtimeDate',
      label: '加班日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择加班日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'overtimeType',
      label: '加班类型',
      component: 'Select',
      componentProps: {
        options: OVERTIME_TYPE_OPTIONS,
        placeholder: '请选择加班类型',
      },
      help: '不同类型对应不同的薪资计算规则',
      rules: 'selectRequired',
    },
    {
      fieldName: 'startTime',
      label: '开始时间',
      component: 'TimePicker',
      componentProps: {
        format: 'HH:mm',
        minuteStep: 30,
        placeholder: '请选择开始时间',
        valueFormat: 'HH:mm:ss',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'endTime',
      label: '结束时间',
      component: 'TimePicker',
      componentProps: {
        format: 'HH:mm',
        minuteStep: 30,
        placeholder: '请选择结束时间',
        valueFormat: 'HH:mm:ss',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'crossDay',
      label: '是否跨天',
      component: 'Switch',
      componentProps: {
        checkedChildren: '是',
        disabled: true,
        unCheckedChildren: '否',
      },
      defaultValue: false,
      help: '结束时间早于或等于开始时间时,系统自动标记为跨天',
    },
    {
      fieldName: 'mealDeduction',
      label: '扣除用餐时间',
      component: 'Switch',
      componentProps: {
        checkedChildren: '扣除',
        unCheckedChildren: '不扣除',
      },
      defaultValue: false,
    },
    {
      fieldName: 'mealDeductionHours',
      label: '用餐扣除时长',
      component: 'InputNumber',
      componentProps: {
        min: 0.5,
        max: 4,
        precision: 1,
        step: 0.5,
        style: { width: '100%' },
      },
      defaultValue: 1,
      suffix: '小时',
      dependencies: {
        triggerFields: ['mealDeduction'],
        show: (values) => Boolean(values.mealDeduction),
      },
    },
    {
      fieldName: 'duration',
      label: '加班时长',
      component: 'InputNumber',
      componentProps: {
        disabled: true,
        min: 0,
        precision: 1,
        step: 0.5,
        style: { width: '100%' },
      },
      help: '按实际时段扣除用餐时间后,自动取整到 0.5 å°æ—¶',
      suffix: '小时',
      rules: 'required',
    },
    {
      fieldName: 'reasonInfoDivider',
      label: '加班事由',
      component: 'Divider',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'reason',
      label: '加班原因',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        maxlength: 500,
        placeholder: '请说明加班的必要性和原因',
        rows: 3,
        showCount: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'relatedType',
      label: '关联类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: RELATED_TYPE_OPTIONS,
        placeholder: '可选:项目或工单',
      },
    },
    {
      fieldName: 'relatedCode',
      label: '项目/工单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        maxlength: 100,
        placeholder: '请输入关联项目或工单编号',
      },
      dependencies: {
        triggerFields: ['relatedType'],
        componentProps: (values) => ({
          allowClear: true,
          maxlength: 100,
          placeholder: values.relatedType === 1
            ? '请输入项目编号'
            : values.relatedType === 2
              ? '请输入工单编号'
              : '请先选择关联类型',
          disabled: !values.relatedType,
        }),
      },
    },
    {
      fieldName: 'workContent',
      label: '工作内容简述',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        maxlength: 1000,
        placeholder: '请简要描述加班期间计划完成的具体工作',
        rows: 4,
        showCount: true,
      },
    },
  ];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'overtimeDate',
      label: '加班日期',
      component: 'DatePicker',
      componentProps: {
        allowClear: true,
        placeholder: '请选择加班日期',
        valueFormat: 'YYYY-MM-DD',
      },
    },
    {
      fieldName: 'overtimeType',
      label: '加班类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: OVERTIME_TYPE_OPTIONS,
        placeholder: '请选择加班类型',
      },
    },
    {
      fieldName: 'status',
      label: '审批状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.HRM_AUDIT_STATUS, 'number'),
        placeholder: '请选择审批状态',
      },
    },
  ];
}
/** åˆ—表字段 */
export function useGridColumns(): VxeTableGridOptions<HrmOvertimeApi.Overtime>['columns'] {
  return [
    { field: 'id', title: '申请编号', width: 90 },
    { field: 'employeeName', title: '申请人', minWidth: 100 },
    { field: 'employeeNo', title: '工号', minWidth: 110 },
    { field: 'deptName', title: '所属部门', minWidth: 120 },
    { field: 'overtimeDate', title: '加班日期', width: 120 },
    {
      field: 'overtimeType',
      title: '加班类型',
      width: 130,
      slots: { default: 'overtimeType' },
    },
    { field: 'startTime', title: '开始时间', width: 100 },
    { field: 'endTime', title: '结束时间', width: 100 },
    { field: 'duration', title: '时长(小时)', width: 100 },
    {
      field: 'status',
      title: '审批状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.HRM_AUDIT_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/hrm/overtime/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { HrmOvertimeApi } from '#/api/hrm/overtime';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
import { getOvertime } from '#/api/hrm/overtime';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
import { getOvertimeTypeLabel } from '#/views/hrm/overtime/data';
defineOptions({ name: 'HrmOvertimeApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<HrmOvertimeApi.Overtime>();
function textRender(value: unknown) {
  return value === 0 || value ? String(value) : '-';
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '申请人', field: 'employeeName', render: textRender },
    { label: '工号', field: 'employeeNo', render: textRender },
    { label: '所属部门', field: 'deptName', render: textRender },
    { label: '岗位/职级', field: 'postName', render: textRender },
    { label: '加班日期', field: 'overtimeDate', render: textRender },
    {
      label: '加班类型',
      field: 'overtimeType',
      render: (value) => h(Tag, { color: value === 3 ? 'red' : value === 2 ? 'orange' : 'blue' }, () => getOvertimeTypeLabel(Number(value))),
    },
    { label: '开始时间', field: 'startTime', render: textRender },
    { label: '结束时间', field: 'endTime', render: textRender },
    { label: '加班时长', field: 'duration', render: (value) => `${textRender(value)} å°æ—¶` },
    { label: '是否跨天', field: 'crossDay', render: (value) => value ? '是' : '否' },
    { label: '用餐扣除', field: 'mealDeduction', render: (value) => value ? '是' : '否' },
    { label: '扣除时长', field: 'mealDeductionHours', render: (value) => value ? `${value} å°æ—¶` : '-' },
    { label: '加班原因', field: 'reason', render: textRender },
    { label: '关联类型', field: 'relatedType', render: (value) => value === 1 ? '项目' : value === 2 ? '工单' : '-' },
    { label: '项目/工单编号', field: 'relatedCode', render: textRender },
    { label: '工作内容简述', field: 'workContent', render: textRender },
    {
      label: '审批状态',
      field: 'status',
      render: (value) => h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value }),
    },
    { label: '创建时间', field: 'createTime', render: (value) => formatDateTime(value) as string },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  loading.value = true;
  try {
    formData.value = await getOvertime(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="rounded-lg border border-border bg-background p-4">
        <div class="mb-4 flex items-center gap-2">
          <span class="inline-block h-4 w-1 rounded bg-primary" />
          <span class="text-base font-semibold">加班申请信息</span>
        </div>
        <Descriptions :data="formData" />
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/hrm/overtime/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,200 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmOvertimeApi } from '#/api/hrm/overtime';
import { ref } from 'vue';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { message, Modal, Select, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  cancelOvertime,
  deleteOvertime,
  getOvertimeApproveProcessDefinitionList,
  getOvertimePage,
  submitOvertime,
} from '#/api/hrm/overtime';
import { $t } from '#/locales';
import {
  getOvertimeTypeLabel,
  useGridColumns,
  useGridFormSchema,
} from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleDetail(row: HrmOvertimeApi.Overtime) {
  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
}
function handleEdit(row: HrmOvertimeApi.Overtime) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
}
async function handleDelete(row: HrmOvertimeApi.Overtime) {
  const hideLoading = message.loading({ content: '正在删除加班申请...', duration: 0 });
  try {
    await deleteOvertime(row.id!);
    message.success('加班申请删除成功');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const submitModalVisible = ref(false);
const currentRow = ref<HrmOvertimeApi.Overtime>();
const processList = ref<HrmOvertimeApi.ApproveProcess[]>([]);
const selectedProcessKey = ref<string>();
const submitLoading = ref(false);
async function handleSubmit(row: HrmOvertimeApi.Overtime) {
  processList.value = await getOvertimeApproveProcessDefinitionList();
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  submitModalVisible.value = true;
}
async function confirmSubmit() {
  if (!selectedProcessKey.value) {
    message.warning('请选择协同办公审批流程');
    return;
  }
  submitLoading.value = true;
  try {
    await submitOvertime(currentRow.value!.id!, selectedProcessKey.value);
    message.success('已提交至协同办公审批');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
    submitLoading.value = false;
  }
}
async function handleCancel(row: HrmOvertimeApi.Overtime) {
  await cancelOvertime(row.id!);
  message.success('撤销成功');
  handleRefresh();
}
function getOvertimeTypeColor(type?: number) {
  return type === 1 ? 'blue' : type === 2 ? 'orange' : type === 3 ? 'red' : 'default';
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: { schema: useGridFormSchema() },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getOvertimePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<HrmOvertimeApi.Overtime>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="加班申请列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['加班申请']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:overtime:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #overtimeType="{ row }">
        <Tag :color="getOvertimeTypeColor(row.overtimeType)">
          {{ getOvertimeTypeLabel(row.overtimeType) }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            { label: '查看', type: 'link', onClick: handleDetail.bind(null, row) },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['hrm:overtime:update'],
              ifShow: row.status === 0,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交',
              type: 'link',
              auth: ['hrm:overtime:submit'],
              ifShow: row.status === 0,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '撤销',
              type: 'link',
              auth: ['hrm:overtime:cancel'],
              ifShow: row.status === 10,
              onClick: handleCancel.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['hrm:overtime:delete'],
              ifShow: row.status === 0 || row.status === 40,
              popConfirm: {
                title: '确认删除该加班申请吗?',
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
    <Modal
      v-model:open="submitModalVisible"
      title="提交至协同办公"
      width="420px"
      :confirm-loading="submitLoading"
      @ok="confirmSubmit"
    >
      <Select
        v-model:value="selectedProcessKey"
        class="w-full"
        placeholder="请选择加班审批流程"
        :options="processList.map((item) => ({ label: item.name, value: item.key }))"
      />
    </Modal>
  </Page>
</template>
src/views/hrm/overtime/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,159 @@
<script lang="ts" setup>
import type { HrmOvertimeApi } from '#/api/hrm/overtime';
import type { FormType } from '../data';
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createOvertime,
  getCurrentOvertimeApplicant,
  getOvertime,
  updateOvertime,
} from '#/api/hrm/overtime';
import { $t } from '#/locales';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const isDetail = computed(() => formType.value === 'detail');
const getTitle = computed(() => {
  if (isDetail.value) return '查看加班申请';
  return formType.value === 'update' ? '修改加班申请' : '新增加班申请';
});
/** æ ¹æ®æ—¶æ®µã€è·¨å¤©æ ‡è¯†å’Œç”¨é¤æ‰£é™¤è®¡ç®—加班时长,并按 0.5 å°æ—¶å–æ•´ */
function calculateDuration(values: Record<string, any>) {
  const { startTime, endTime } = values;
  if (!startTime || !endTime) return { crossDay: false, duration: 0 };
  const toMinutes = (time: string) => {
    const [hour = 0, minute = 0] = time.split(':').map(Number);
    return hour * 60 + minute;
  };
  const startMinutes = toMinutes(startTime);
  let endMinutes = toMinutes(endTime);
  const crossDay = endMinutes <= startMinutes;
  if (crossDay) endMinutes += 24 * 60;
  const deductionMinutes = values.mealDeduction
    ? Number(values.mealDeductionHours || 0) * 60
    : 0;
  const actualHours = Math.max(0, (endMinutes - startMinutes - deductionMinutes) / 60);
  return {
    crossDay,
    duration: Math.round(actualHours * 2) / 2,
  };
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: [],
  scrollToFirstError: true,
  showDefaultActions: false,
  handleValuesChange: (values, changedFields) => {
    const durationFields = [
      'startTime',
      'endTime',
      'mealDeduction',
      'mealDeductionHours',
    ];
    if (changedFields.some((field) => durationFields.includes(field))) {
      const result = calculateDuration(values);
      void formApi.setValues(result);
    }
    if (changedFields.includes('relatedType') && !values.relatedType) {
      void formApi.setFieldValue('relatedCode', undefined);
    }
  },
});
async function setCurrentEmployee() {
  const employee = await getCurrentOvertimeApplicant();
  await formApi.setValues({
    employeeId: employee.employeeId,
    employeeName: employee.employeeName,
    employeeNo: employee.employeeNo,
    deptName: employee.deptName,
    postName: employee.postName,
    mealDeduction: false,
    mealDeductionHours: 1,
    crossDay: false,
    duration: 0,
  });
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (isDetail.value) {
      await modalApi.close();
      return;
    }
    const { valid } = await formApi.validate();
    if (!valid) return;
    const data = (await formApi.getValues()) as HrmOvertimeApi.Overtime;
    if (!data.employeeId) {
      message.warning('当前账号未关联员工档案,暂时无法提交加班申请');
      return;
    }
    if (!data.duration || data.duration <= 0) {
      message.warning('加班时长必须大于 0 å°æ—¶ï¼Œè¯·æ£€æŸ¥æ—¶æ®µæˆ–用餐扣除');
      return;
    }
    if (data.relatedType && !data.relatedCode?.trim()) {
      message.warning('请选择关联类型后填写对应的项目或工单编号');
      return;
    }
    modalApi.lock();
    try {
      if (data.id) await updateOvertime(data);
      else await createOvertime(data);
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) return;
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema() });
    formApi.setDisabled(isDetail.value);
    modalApi.setState({ showConfirmButton: !isDetail.value });
    if (!data.id) {
      await setCurrentEmployee();
      return;
    }
    modalApi.lock();
    try {
      await formApi.setValues(await getOvertime(data.id));
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-[900px]">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/hrm/resignation/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { HrmResignationApi } from '#/api/hrm/resignation';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { getResignation } from '#/api/hrm/resignation';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'HrmResignationApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<HrmResignationApi.Resignation>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '员工姓名', field: 'employeeName', render: textRender },
    { label: '所属部门', field: 'deptName', render: textRender },
    { label: '职位', field: 'postName', render: textRender },
    {
      label: '离职类型',
      field: 'resignationType',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.HRM_RESIGNATION_TYPE, value: val }),
    },
    {
      label: '预计离职日期',
      field: 'expectedDate',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '离职原因', field: 'reason', render: textRender },
    { label: '交接人', field: 'handoverUserName', render: textRender },
    {
      label: '交接状态',
      field: 'handoverStatus',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.HRM_HANDOVER_STATUS, value: val }),
    },
    {
      label: '审批状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value: val }),
    },
    { label: '备注', field: 'remark', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getResignation(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- ç¦»èŒåŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">离职基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/hrm/salary/calculation/data.ts
@@ -89,6 +89,7 @@
    { field: 'actualSalary', title: '实发工资', width: 100, formatter: 'formatAmount2' },
    { field: 'workDays', title: '出勤天数', width: 80 },
    { field: 'overtimeHours', title: '加班时长(h)', width: 100 },
    { field: 'totalWorkHours', title: '排班工时(h)', width: 100 },
    {
      field: 'status',
      title: '状态',
@@ -127,6 +128,7 @@
    { title: '请假扣款', dataIndex: 'leaveDeduction', width: 110 },
    { title: '出勤天数', dataIndex: 'workDays', width: 90 },
    { title: '加班时长', dataIndex: 'overtimeHours', width: 100 },
    { title: '排班工时(h)', dataIndex: 'totalWorkHours', width: 100 },
    { title: '实发工资', dataIndex: 'actualSalary', width: 100, fixed: 'right' },
  ];
}
src/views/hrm/salary/calculation/index.vue
@@ -16,9 +16,11 @@
import {
  calculateSalary,
  confirmSalary,
  deleteSalaryCalculation,
  exportSalaryCalculation,
  getSalaryCalculationPage,
  previewSalaryCalculation,
  revokeSalaryCalculation,
  saveSalaryCalculation,
} from '#/api/hrm/salary/calculation';
import { getDeptList } from '#/api/system/dept';
@@ -153,7 +155,7 @@
  handleRefresh();
}
/** æ‰¹é‡ç¡®è®¤ */
/** æ‰¹é‡ç¡®è®¤é€‰ä¸­çš„记录 */
async function handleBatchConfirm() {
  const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[];
  if (records.length === 0) {
@@ -164,6 +166,58 @@
  await confirmSalary(ids);
  message.success('确认成功');
  handleRefresh();
}
/** æ‰¹é‡ä¿å­˜é€‰ä¸­çš„æ ¸ç®—记录(仅待确认状态) */
async function handleBatchSave() {
  const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[];
  if (records.length === 0) {
    message.warning('请选择要保存的记录');
    return;
  }
  const pending = records.filter((r) => r.status === 0);
  if (pending.length === 0) {
    message.warning('选中的记录均非待确认状态,无需保存');
    return;
  }
  const first = pending[0];
  if (!first?.period) {
    message.warning('缺少核算周期');
    return;
  }
  await saveSalaryCalculation({ period: first.period, list: pending });
  message.success('批量保存成功');
  handleRefresh();
}
/** æ’¤é”€ç¡®è®¤ï¼ˆå·²ç¡®è®¤ â†’ å¾…确认,可重新核算) */
async function handleRevoke(row: HrmSalaryCalculationApi.SalaryCalculation) {
  Modal.confirm({
    title: '撤销确认',
    content: `确定撤销【${row.no}】的确认吗?撤销后可重新核算。`,
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      await revokeSalaryCalculation([row.id!]);
      message.success('撤销成功');
      handleRefresh();
    },
  });
}
/** åˆ é™¤å¾…确认的核算记录 */
async function handleDelete(row: HrmSalaryCalculationApi.SalaryCalculation) {
  Modal.confirm({
    title: '确认删除',
    content: `确定删除【${row.no}】的核算记录吗?删除后需重新核算。`,
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      await deleteSalaryCalculation(row.id!);
      message.success('删除成功');
      handleRefresh();
    },
  });
}
/** å¯¼å‡ºè–ªé…¬æ ¸ç®— */
@@ -182,7 +236,6 @@
    keepSource: true,
    checkboxConfig: {
      highlight: true,
      reserve: true,
    },
    proxyConfig: {
      ajax: {
@@ -193,6 +246,10 @@
            ...formValues,
          }),
      },
    },
    pagingConfig: {
      pageSize: 20,
      pageSizes: [20, 50, 100],
    },
    rowConfig: {
      keyField: 'id',
@@ -261,7 +318,7 @@
        v-if="previewData.length > 0"
        :columns="usePreviewColumns()"
        :data-source="previewData"
        :pagination="false"
        :pagination="{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `共 ${t} äºº` }"
        :scroll="{ x: 1800, y: 400 }"
        size="small"
        row-key="userId"
@@ -430,6 +487,12 @@
              onClick: handleBatchConfirm,
            },
            {
              label: '批量保存',
              type: 'primary',
              auth: ['hrm:salary-calculation:save'],
              onClick: handleBatchSave,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
@@ -454,6 +517,21 @@
              ifShow: row.status === 0,
              onClick: handleConfirm.bind(null, row),
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              auth: ['hrm:salary-calculation:delete'],
              ifShow: row.status === 0,
              onClick: handleDelete.bind(null, row),
            },
            {
              label: '撤销确认',
              type: 'link',
              auth: ['hrm:salary-calculation:confirm'],
              ifShow: row.status === 10,
              onClick: handleRevoke.bind(null, row),
            },
          ]"
        />
      </template>
src/views/hrm/salary/leave-type-config/data.ts
@@ -3,7 +3,7 @@
import type { HrmLeaveTypeConfigApi } from '#/api/hrm/leave-type-config';
/** è¡¨å•类型 */
export type FormType = 'update';
export type FormType = 'create' | 'update';
/** æ–°å¢ž/修改请假扣款配置的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -17,13 +17,29 @@
      },
    },
    {
      fieldName: 'name',
      fieldName: 'leaveType',
      label: '请假类型',
      component: 'Input',
      component: 'Select',
      componentProps: {
        disabled: true,
        placeholder: '',
        options: [
          { label: '事假', value: 1 },
          { label: '病假', value: 2 },
          { label: '年假', value: 3 },
          { label: '婚假', value: 4 },
          { label: '产假', value: 5 },
          { label: '丧假', value: 6 },
        ],
        placeholder: '请选择请假类型',
        style: { width: '100%' },
      },
      dependencies: {
        triggerFields: ['id'],
        // ç¼–辑时不允许修改请假类型
        componentProps: (values: Record<string, unknown>) => ({
          disabled: Boolean(values.id),
        }),
      },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
@@ -47,6 +63,7 @@
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      defaultValue: 0,
      componentProps: {
        options: [
          { label: '启用', value: 0 },
src/views/hrm/salary/leave-type-config/index.vue
@@ -7,7 +7,10 @@
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getLeaveTypeConfigList } from '#/api/hrm/leave-type-config';
import {
  deleteLeaveTypeConfig,
  getLeaveTypeConfigList,
} from '#/api/hrm/leave-type-config';
import { $t } from '#/locales';
import { useGridColumns } from './data';
@@ -23,9 +26,21 @@
  gridApi.query();
}
/** æ–°å¢žé…ç½® */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑配置 */
function handleEdit(row: HrmLeaveTypeConfigApi.LeaveTypeConfig) {
  formModalApi.setData({ id: row.id, row }).open();
  formModalApi.setData({ formType: 'update', row }).open();
}
/** åˆ é™¤é…ç½® */
async function handleDelete(row: HrmLeaveTypeConfigApi.LeaveTypeConfig) {
  await deleteLeaveTypeConfig(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['请假扣款配置']));
  handleRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -56,6 +71,19 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="请假扣款配置列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['请假扣款配置']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:leave-type-config:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #deductionRatio="{ row }">
        <Tag :color="row.deductionRatio === 0 ? 'success' : row.deductionRatio === 1 ? 'error' : 'warning'">
          {{ row.deductionRatio === 0 ? '不扣款' : `${(row.deductionRatio * 100).toFixed(0)}%` }}
@@ -76,6 +104,17 @@
              auth: ['hrm:leave-type-config:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['hrm:leave-type-config:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', ['请假扣款配置']),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
src/views/hrm/salary/leave-type-config/modules/form.vue
@@ -1,19 +1,28 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { HrmLeaveTypeConfigApi } from '#/api/hrm/leave-type-config';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateLeaveTypeConfig } from '#/api/hrm/leave-type-config';
import {
  createLeaveTypeConfig,
  updateLeaveTypeConfig,
} from '#/api/hrm/leave-type-config';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const getTitle = computed(() =>
  formType.value === 'update' ? '编辑扣款比例' : '新增请假扣款配置',
);
const [Form, formApi] = useVbenForm({
  commonConfig: {
@@ -36,11 +45,35 @@
      return;
    }
    modalApi.lock();
    const data = await formApi.getValues();
    const data = (await formApi.getValues()) as HrmLeaveTypeConfigApi.LeaveTypeConfig;
    // è½¬æ¢æ¯”例为小数
    data.deductionRatio = data.deductionRatio / 100;
    const deductionRatio = data.deductionRatio! / 100;
    try {
      await updateLeaveTypeConfig(data);
      if (formType.value === 'update') {
        await updateLeaveTypeConfig({
          id: data.id,
          deductionRatio,
          remark: data.remark,
          status: data.status,
        });
      } else {
        // è¯·å‡ç±»åž‹åç§°ç”±æ‰€é€‰ç±»åž‹è‡ªåŠ¨å¸¦å‡ºï¼Œæ— éœ€æ‰‹åŠ¨å¡«å†™
        const NAME_MAP: Record<number, string> = {
          1: '事假',
          2: '病假',
          3: '年假',
          4: '婚假',
          5: '产假',
          6: '丧假',
        };
        await createLeaveTypeConfig({
          leaveType: data.leaveType,
          name: NAME_MAP[data.leaveType!] ?? '',
          deductionRatio,
          remark: data.remark,
          status: data.status,
        });
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
@@ -52,16 +85,24 @@
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ id: number; row: HrmLeaveTypeConfigApi.LeaveTypeConfig }>();
    const data = modalApi.getData<{ formType: FormType; row: HrmLeaveTypeConfigApi.LeaveTypeConfig }>();
    formType.value = data.formType;
    if (data.formType === 'create' || !data.row) {
      await formApi.resetForm();
      return;
    }
    // è½¬æ¢æ¯”例为百分比显示
    const row = { ...data.row, deductionRatio: (data.row.deductionRatio || 0) * 100 };
    const row = {
      ...data.row,
      deductionRatio: (data.row.deductionRatio || 0) * 100,
    };
    await formApi.setValues(row);
  },
});
</script>
<template>
  <Modal title="编辑扣款比例" class="w-[600px]">
  <Modal :title="getTitle" class="w-[600px]">
    <Form class="mx-4" />
  </Modal>
</template>
</template>
src/views/hrm/salary/structure/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmSalaryStructureApi } from '#/api/hrm/salary/structure';
export type FormType = 'create' | 'update';
const statusOptions = [
  { label: '启用', value: 0 },
  { label: '禁用', value: 1 },
];
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'name',
      label: '结构名称',
      component: 'Input',
      componentProps: {
        maxlength: 100,
        placeholder: '例如:标准员工薪酬、管理层薪酬',
        showCount: true,
      },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'overtimePayRatio',
      label: '工作日倍率',
      component: 'InputNumber',
      componentProps: {
        addonAfter: '倍',
        max: 10,
        min: 0,
        precision: 2,
        step: 0.1,
        placeholder: '0 è¡¨ç¤ºä¸è®¡åŠ ç­å·¥èµ„',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'restDayOvertimePayRatio',
      label: '休息日倍率',
      component: 'InputNumber',
      componentProps: {
        addonAfter: '倍',
        max: 10,
        min: 0,
        precision: 2,
        step: 0.1,
        placeholder: '0 è¡¨ç¤ºä¸è®¡è¯¥ç±»åž‹åŠ ç­å·¥èµ„',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'legalHolidayOvertimePayRatio',
      label: '法定节假日倍率',
      component: 'InputNumber',
      componentProps: {
        addonAfter: '倍',
        max: 10,
        min: 0,
        precision: 2,
        step: 0.1,
        placeholder: '0 è¡¨ç¤ºä¸è®¡è¯¥ç±»åž‹åŠ ç­å·¥èµ„',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: { options: statusOptions },
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        maxlength: 500,
        placeholder: '填写适用岗位、职级或特殊计薪说明',
        rows: 3,
        showCount: true,
      },
      formItemClass: 'col-span-2',
    },
  ];
}
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '结构名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入结构名称',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: statusOptions,
        placeholder: '请选择状态',
      },
    },
  ];
}
export function useGridColumns(): VxeTableGridOptions<HrmSalaryStructureApi.SalaryStructure>['columns'] {
  return [
    { field: 'name', title: '结构名称', minWidth: 180 },
    {
      field: 'overtimePayRatio',
      title: '工作日加班倍率',
      width: 145,
      slots: { default: 'overtimePayRatio' },
    },
    {
      field: 'restDayOvertimePayRatio',
      title: '休息日加班倍率',
      width: 145,
      slots: { default: 'restDayOvertimePayRatio' },
    },
    {
      field: 'legalHolidayOvertimePayRatio',
      title: '法定节假日倍率',
      width: 165,
      slots: { default: 'legalHolidayOvertimePayRatio' },
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      slots: { default: 'status' },
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      field: 'actions',
      title: '操作',
      fixed: 'right',
      slots: { default: 'actions' },
      width: 150,
    },
  ];
}
src/views/hrm/salary/structure/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,137 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmSalaryStructureApi } from '#/api/hrm/salary/structure';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { Alert, message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteSalaryStructure,
  getSalaryStructureList,
} from '#/api/hrm/salary/structure';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'HrmSalaryStructure' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: HrmSalaryStructureApi.SalaryStructure) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
}
async function handleDelete(row: HrmSalaryStructureApi.SalaryStructure) {
  await deleteSalaryStructure(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['薪酬结构']));
  handleRefresh();
}
function formatRatio(value?: number) {
  return value === undefined || value === null ? '-' : `${value.toFixed(2)} å€`;
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: { schema: useGridFormSchema() },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: { enabled: false },
    proxyConfig: {
      ajax: {
        query: async (_params, formValues) =>
          await getSalaryStructureList(formValues),
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<HrmSalaryStructureApi.SalaryStructure>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Alert
      class="mb-4"
      description="基本工资取员工档案;社保和公积金基数取员工社保档案。这里仅维护工作日、休息日和法定节假日加班倍率。"
      message="薪酬结构维护加班计薪规则"
      show-icon
      type="info"
    />
    <Grid table-title="薪酬结构列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['薪酬结构']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:salary-structure:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #overtimePayRatio="{ row }">
        <Tag :color="(row.overtimePayRatio ?? 0) > 0 ? 'blue' : 'default'">
          {{ (row.overtimePayRatio ?? 0) > 0 ? formatRatio(row.overtimePayRatio) : '不计加班工资' }}
        </Tag>
      </template>
      <template #restDayOvertimePayRatio="{ row }">
        <Tag :color="(row.restDayOvertimePayRatio ?? 0) > 0 ? 'blue' : 'default'">
          {{ (row.restDayOvertimePayRatio ?? 0) > 0 ? formatRatio(row.restDayOvertimePayRatio) : '不计加班工资' }}
        </Tag>
      </template>
      <template #legalHolidayOvertimePayRatio="{ row }">
        <Tag :color="(row.legalHolidayOvertimePayRatio ?? 0) > 0 ? 'blue' : 'default'">
          {{ (row.legalHolidayOvertimePayRatio ?? 0) > 0 ? formatRatio(row.legalHolidayOvertimePayRatio) : '不计加班工资' }}
        </Tag>
      </template>
      <template #status="{ row }">
        <Tag :color="row.status === 0 ? 'success' : 'default'">
          {{ row.status === 0 ? '启用' : '禁用' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['hrm:salary-structure:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['hrm:salary-structure:delete'],
              popConfirm: {
                title: '确认删除该薪酬结构吗?已被员工使用的结构不可删除。',
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/salary/structure/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { HrmSalaryStructureApi } from '#/api/hrm/salary/structure';
import type { FormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createSalaryStructure,
  getSalaryStructure,
  updateSalaryStructure,
} from '#/api/hrm/salary/structure';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const getTitle = computed(() =>
  formType.value === 'update' ? '修改薪酬结构' : '新增薪酬结构',
);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 110,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) return;
    modalApi.lock();
    try {
      const data =
        (await formApi.getValues()) as HrmSalaryStructureApi.SalaryStructure;
      if (data.id) {
        await updateSalaryStructure(data);
      } else {
        await createSalaryStructure(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) return;
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    await formApi.resetForm();
    if (!data.id) {
      await formApi.setValues({
        legalHolidayOvertimePayRatio: 3,
        overtimePayRatio: 1.5,
        restDayOvertimePayRatio: 2,
        status: 0,
      });
      return;
    }
    modalApi.lock();
    try {
      await formApi.setValues(await getSalaryStructure(data.id));
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-[680px]">
    <Alert
      class="mx-4 mb-4"
      description="三种加班类型分别设置倍率;某一项设为 0 æ—¶ï¼Œä»…该类型不计算加班工资,审批通过的加班时长仍正常统计。"
      message="加班工资计算说明"
      show-icon
      type="info"
    />
    <Form class="mx-4" />
  </Modal>
</template>
src/views/hrm/schedule/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
/** æŽ’班网格的日期工具 */
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
/** è¿”回某月天数数组,如 2026-08 â†’ [1..31] */
export function getDaysOfMonth(yearMonth: string): number[] {
  const [yearStr, monthStr] = yearMonth.split('-');
  const days = new Date(Number(yearStr), Number(monthStr), 0).getDate();
  return Array.from({ length: days }, (_, i) => i + 1);
}
/** è¿”回某日的星期中文标签(日/一/二…) */
export function getWeekLabel(yearMonth: string, day: number): string {
  const date = new Date(`${yearMonth}-${String(day).padStart(2, '0')}`);
  return WEEK_LABELS[date.getDay()] ?? '';
}
/** åˆ¤æ–­æŸæ—¥æ˜¯å¦ä¸ºå‘¨æœ« */
export function isWeekend(yearMonth: string, day: number): boolean {
  const date = new Date(`${yearMonth}-${String(day).padStart(2, '0')}`);
  const week = date.getDay();
  return week === 0 || week === 6;
}
/** ç”ŸæˆæŸæœˆæŸæ—¥çš„æ—¥æœŸå­—符串 yyyy-MM-dd */
export function formatDate(yearMonth: string, day: number): string {
  return `${yearMonth}-${String(day).padStart(2, '0')}`;
}
/** è‡ªåŠ¨æŽ’ç­ä¼‘æ¯æ¨¡å¼ */
export const REST_MODE = {
  /** åŒä¼‘:周六、周日休息 */
  DOUBLE: 'double',
  /** å•休:周日休息 */
  SINGLE: 'single',
  /** ä¸ä¼‘息:每天排班 */
  NONE: 'none',
} as const;
export type RestMode = (typeof REST_MODE)[keyof typeof REST_MODE];
export const REST_MODE_OPTIONS: { label: string; value: RestMode }[] = [
  { label: '双休(六日休)', value: REST_MODE.DOUBLE },
  { label: '单休(周日休)', value: REST_MODE.SINGLE },
  { label: '不休息', value: REST_MODE.NONE },
];
src/views/hrm/schedule/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,743 @@
<script lang="ts" setup>
import type { HrmEmployeeApi } from '#/api/hrm/employee';
import type { HrmEmployeeScheduleApi } from '#/api/hrm/schedule';
import type { HrmShiftApi } from '#/api/hrm/shift';
import { computed, onMounted, ref, watch } from 'vue';
import dayjs from 'dayjs';
import {
  Button,
  Checkbox,
  DatePicker,
  Empty,
  Input,
  message,
  Modal,
  Pagination,
  Radio,
  Segmented,
  Select,
  TreeSelect,
} from 'ant-design-vue';
import { Page } from '#/packages/effects/common-ui/src';
import { getEmployeePage } from '#/api/hrm/employee';
import { getShiftList } from '#/api/hrm/shift';
import { getEmployeeScheduleList, saveEmployeeSchedule } from '#/api/hrm/schedule';
import { getDeptList } from '#/api/system/dept';
import { handleTree } from '#/packages/utils/src';
import {
  REST_MODE,
  REST_MODE_OPTIONS,
  formatDate,
  getDaysOfMonth,
} from './data';
import type { RestMode } from './data';
defineOptions({ name: 'HrmSchedule' });
// ========== æŸ¥è¯¢æ¡ä»¶ ==========
const month = ref<string>('');
const deptId = ref<number>();
const deptTree = ref<any[]>([]);
/** å‘˜å·¥åˆ†é¡µï¼ˆæŽ’班网格每次只显示一页员工) */
const employeePageNo = ref(1);
const employeePageSize = ref(20);
const employeeTotal = ref(0);
/** æŒ‰å‘˜å·¥ç¼–号/姓名/手机号搜索单个员工 */
const keyword = ref('');
/** ä¿å­˜æ—¶å•次请求的排班条目数(防止大批量请求过大) */
const SAVE_BATCH_SIZE = 1000;
// ========== æ•°æ® ==========
const employees = ref<HrmEmployeeApi.Employee[]>([]);
const shiftList = ref<HrmShiftApi.Shift[]>([]);
const loading = ref(false);
const saving = ref(false);
/** æ˜¯å¦å·²æ‰§è¡Œè¿‡æŸ¥è¯¢ */
const loaded = ref(false);
// ========== å‘˜å·¥å‹¾é€‰ ==========
/** Checkbox å˜æ›´äº‹ä»¶ï¼ˆä»…取 checked å­—段) */
interface CheckChangeEvent {
  target: { checked: boolean };
}
/** å‹¾é€‰çš„员工ID集合,为空时自动排班作用于全部员工 */
const checkedEmployeeIds = ref<Set<number>>(new Set());
const isAllChecked = computed(
  () =>
    employees.value.length > 0 &&
    checkedEmployeeIds.value.size === employees.value.length,
);
function isChecked(id: number): boolean {
  return checkedEmployeeIds.value.has(id);
}
function handleCheck(id: number, e: CheckChangeEvent) {
  const set = new Set(checkedEmployeeIds.value);
  if (e.target.checked) {
    set.add(id);
  } else {
    set.delete(id);
  }
  checkedEmployeeIds.value = set;
}
function handleCheckAll(e: CheckChangeEvent) {
  if (e.target.checked) {
    checkedEmployeeIds.value = new Set(
      employees.value
        .map((item) => item.id)
        .filter((id): id is number => id != null),
    );
  } else {
    checkedEmployeeIds.value = new Set();
  }
}
// ========== è‡ªåŠ¨æŽ’ç­ ==========
const autoOpen = ref(false);
const autoShiftId = ref<number>();
const autoRestMode = ref<RestMode>(REST_MODE.DOUBLE);
/** è‡ªåŠ¨æŽ’ç­ç­æ¬¡é€‰æ‹©é€‰é¡¹ */
const autoShiftOptions = computed(() =>
  shiftList.value.map((shift) => ({
    label: `${shift.name} (${shift.startTime}~${shift.endTime})`,
    value: shift.id,
  })),
);
/** æŒ‰ä¼‘息模式为当前员工生成整月排班(休息日清除,工作日填班次) */
function applyAutoSchedule() {
  if (!employees.value.length || !days.value.length) {
    message.warning('请先查询出员工');
    return;
  }
  if (autoShiftId.value == null) {
    message.warning('请选择排班班次');
    return;
  }
  // å‹¾é€‰äº†å‘˜å·¥åˆ™åªå¯¹å‹¾é€‰äººç”Ÿæ•ˆï¼Œæœªå‹¾é€‰åˆ™ä½œç”¨äºŽå…¨éƒ¨å‘˜å·¥
  const targets =
    checkedEmployeeIds.value.size > 0
      ? employees.value.filter(
          (item) =>
            item.id != null && checkedEmployeeIds.value.has(item.id),
        )
      : employees.value;
  if (!targets.length) {
    message.warning('请先勾选员工');
    return;
  }
  const map = new Map(scheduleMap.value);
  for (const emp of targets) {
    if (emp.id == null) {
      continue;
    }
    for (const day of days.value) {
      const date = formatDate(month.value, day);
      const week = new Date(
        `${month.value}-${String(day).padStart(2, '0')}`,
      ).getDay();
      let rest = false;
      if (autoRestMode.value === REST_MODE.DOUBLE) {
        rest = week === 0 || week === 6;
      } else if (autoRestMode.value === REST_MODE.SINGLE) {
        rest = week === 0;
      }
      map.set(`${emp.id}_${date}`, rest ? null : autoShiftId.value);
    }
  }
  scheduleMap.value = map;
  autoOpen.value = false;
  message.success('自动排班已生成,可手动微调后保存');
}
/** ç­æ¬¡ id â†’ ç­æ¬¡ æ˜ å°„ */
const shiftMap = computed(() => {
  const map = new Map<number, HrmShiftApi.Shift>();
  for (const shift of shiftList.value) {
    if (shift.id != null) {
      map.set(shift.id, shift);
    }
  }
  return map;
});
/**
 * æŽ’班数据。key = `${employeeId}_${date}`,value = shiftId(null è¡¨ç¤ºä¼‘息)
 */
const scheduleMap = ref(new Map<string, number | null>());
/** å½“月天数数组 */
const days = computed(() => (month.value ? getDaysOfMonth(month.value) : []));
// ========== è§†å›¾åˆ‡æ¢ï¼ˆå‘¨/月)==========
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
interface ViewDay {
  day: number;
  date: string;
  weekLabel: string;
  isWeekend: boolean;
}
const viewMode = ref<'week' | 'month'>('week');
/** æœˆè§†å›¾æ˜¯å¦æ˜¾ç¤ºå‘¨æœ«åˆ—(默认隐藏,减列缓解拥挤) */
const showWeekend = ref(false);
/** å‘¨è§†å›¾é”šç‚¹æ—¥æœŸ */
const viewAnchor = ref<Date>(new Date());
/** å½“前视图要展示的日期列表 */
const viewDays = computed<ViewDay[]>(() => {
  if (viewMode.value === 'week') {
    // ä»¥å‘¨ä¸€ä¸ºä¸€å‘¨èµ·ç‚¹
    const base = dayjs(viewAnchor.value).day(1);
    return Array.from({ length: 7 }, (_, i) => {
      const date = base.add(i, 'day');
      const week = date.day();
      return {
        day: date.date(),
        date: date.format('YYYY-MM-DD'),
        weekLabel: WEEK_LABELS[week] ?? '',
        isWeekend: week === 0 || week === 6,
      };
    });
  }
  return days.value
    .filter((day) => {
      if (showWeekend.value) {
        return true;
      }
      const week = new Date(
        `${month.value}-${String(day).padStart(2, '0')}`,
      ).getDay();
      return week !== 0 && week !== 6;
    })
    .map((day) => {
      const week = new Date(
        `${month.value}-${String(day).padStart(2, '0')}`,
      ).getDay();
      return {
        day,
        date: formatDate(month.value, day),
        weekLabel: WEEK_LABELS[week] ?? '',
        isWeekend: week === 0 || week === 6,
      };
    });
});
/** å‘¨è§†å›¾å½“前显示范围标签 */
const weekRangeLabel = computed(() => {
  const list = viewDays.value;
  const first = list[0];
  const last = list[list.length - 1];
  if (!first || !last) {
    return '';
  }
  return `${first.date} ~ ${last.date}`;
});
function shiftWeek(delta: number) {
  viewAnchor.value = dayjs(viewAnchor.value).add(delta, 'week').toDate();
}
// æœˆä»½å˜åŒ–时,周视图锚点落到该月 1 å·æ‰€åœ¨å‘¨
watch(month, (value) => {
  if (value) {
    viewAnchor.value = new Date(`${value}-01`);
  }
});
function getShiftByCell(employeeId: number, date: string): HrmShiftApi.Shift | undefined {
  const shiftId = scheduleMap.value.get(`${employeeId}_${date}`);
  if (shiftId == null) {
    return undefined;
  }
  return shiftMap.value.get(shiftId);
}
// ========== ç­æ¬¡é€‰æ‹©å¼¹çª— ==========
const pickerOpen = ref(false);
const pickerEmployee = ref<HrmEmployeeApi.Employee>();
const pickerDate = ref<string>('');
const pickerTitle = computed(() => {
  if (!pickerEmployee.value || !pickerDate.value) {
    return '选择班次';
  }
  return `选择班次 Â· ${pickerEmployee.value.name} Â· ${pickerDate.value}`;
});
function openPicker(emp: HrmEmployeeApi.Employee, date: string) {
  pickerEmployee.value = emp;
  pickerDate.value = date;
  pickerOpen.value = true;
}
/** å½“前弹窗格子是否已选该班次 */
function isCurrentPick(shiftId: number): boolean {
  if (!pickerEmployee.value || !pickerDate.value) {
    return false;
  }
  return (
    scheduleMap.value.get(`${pickerEmployee.value.id}_${pickerDate.value}`) ===
    shiftId
  );
}
function pickShift(shiftId: number | null) {
  if (!pickerEmployee.value || !pickerDate.value) {
    return;
  }
  scheduleMap.value.set(`${pickerEmployee.value.id}_${pickerDate.value}`, shiftId);
  pickerOpen.value = false;
}
// ========== åŠ è½½ ==========
async function loadDeptTree() {
  const data = await getDeptList();
  deptTree.value = handleTree(data);
}
/**
 * åˆ†é¡µæ‹‰å–部门下的全部员工(单页上限 200,循环拼全),供排班网格整表展示
 */
/** åŠ è½½å½“å‰é¡µå‘˜å·¥ï¼ˆç¿»é¡µæ—¶åªé‡æ–°æ‹‰å‘˜å·¥ï¼ŒæŽ’ç­æ•°æ®ä¿ç•™ï¼‰ */
async function loadEmployeePage() {
  loading.value = true;
  try {
    const res = await getEmployeePage({
      pageNo: employeePageNo.value,
      pageSize: employeePageSize.value,
      deptId: deptId.value,
      keyword: keyword.value || undefined,
    });
    employees.value = res.list ?? [];
    employeeTotal.value = res.total ?? 0;
  } finally {
    loading.value = false;
  }
}
/** å‘˜å·¥ç¿»é¡µ */
function handlePageChange(page: number, pageSize: number) {
  if (pageSize !== employeePageSize.value) {
    employeePageSize.value = pageSize;
    employeePageNo.value = 1;
  } else {
    employeePageNo.value = page;
  }
  loadEmployeePage();
}
/** æŒ‰å…³é”®è¯æœç´¢å‘˜å·¥ */
function handleSearch() {
  employeePageNo.value = 1;
  loadEmployeePage();
}
async function loadData() {
  if (!month.value) {
    message.warning('请选择月份');
    return;
  }
  if (!deptId.value) {
    message.warning('请先选择部门');
    return;
  }
  loading.value = true;
  try {
    const [shifts, schedules] = await Promise.all([
      getShiftList(),
      getEmployeeScheduleList({
        yearMonth: month.value,
        deptId: deptId.value,
      }),
    ]);
    shiftList.value = shifts;
    const map = new Map<string, number | null>();
    for (const item of schedules) {
      if (item.employeeId != null && item.date) {
        map.set(`${item.employeeId}_${item.date}`, item.shiftId ?? null);
      }
    }
    scheduleMap.value = map;
    loaded.value = true;
  } finally {
    loading.value = false;
  }
  // åŠ è½½ç¬¬ä¸€é¡µå‘˜å·¥
  employeePageNo.value = 1;
  await loadEmployeePage();
}
// ========== ä¿å­˜ï¼ˆæ•´æœˆå…¨é‡ï¼‰ ==========
async function handleSave() {
  if (!month.value || !employees.value.length) {
    return;
  }
  Modal.confirm({
    title: '确认保存',
    content: `将保存当前页 ${employees.value.length} åå‘˜å·¥åœ¨ ${month.value} çš„全部排班,确定继续吗?`,
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      saving.value = true;
      try {
        const items: HrmEmployeeScheduleApi.ScheduleItem[] = [];
        for (const emp of employees.value) {
          if (emp.id == null) {
            continue;
          }
          for (const day of days.value) {
            const date = formatDate(month.value, day);
            items.push({
              employeeId: emp.id,
              date,
              shiftId: scheduleMap.value.get(`${emp.id}_${date}`) ?? null,
            });
          }
        }
        // å¤§æ‰¹é‡æ—¶åˆ†æ‰¹æäº¤ï¼Œé¿å…å•次请求过大
        for (let i = 0; i < items.length; i += SAVE_BATCH_SIZE) {
          await saveEmployeeSchedule(items.slice(i, i + SAVE_BATCH_SIZE));
        }
        message.success('排班保存成功');
      } finally {
        saving.value = false;
      }
    },
  });
}
onMounted(() => {
  month.value = dayjs().format('YYYY-MM');
  loadDeptTree();
});
</script>
<template>
  <Page auto-content-height>
    <!-- æŸ¥è¯¢å·¥å…·æ  -->
    <div
      class="mb-4 flex flex-wrap items-center gap-3 rounded-lg bg-white p-4"
    >
      <DatePicker
        v-model:value="month"
        picker="month"
        :allow-clear="false"
        value-format="YYYY-MM"
        format="YYYY-MM"
        style="width: 140px"
      />
      <TreeSelect
        v-model:value="deptId"
        :tree-data="deptTree"
        :field-names="{ label: 'name', value: 'id', children: 'children' }"
        allow-clear
        placeholder="请选择部门"
        tree-default-expand-all
        style="width: 200px"
      />
      <Input
        v-model:value="keyword"
        allow-clear
        placeholder="员工编号/姓名/手机号"
        style="width: 200px"
        @pressEnter="handleSearch"
      />
      <Button type="primary" :loading="loading" @click="loadData">
        æŸ¥è¯¢æŽ’班
      </Button>
      <Button :disabled="!employees.length" @click="autoOpen = true">
        è‡ªåŠ¨æŽ’ç­
      </Button>
      <Button
        type="primary"
        :loading="saving"
        :disabled="!employees.length"
        @click="handleSave"
      >
        ä¿å­˜æŽ’班
      </Button>
      <Segmented
        v-model:value="viewMode"
        :options="[
          { label: '周视图', value: 'week' },
          { label: '月视图', value: 'month' },
        ]"
      />
      <template v-if="viewMode === 'week'">
        <Button @click="shiftWeek(-1)">上一周</Button>
        <span class="text-sm text-gray-500">{{ weekRangeLabel }}</span>
        <Button @click="shiftWeek(1)">下一周</Button>
      </template>
      <template v-else>
        <Checkbox v-model:checked="showWeekend">显示周末</Checkbox>
      </template>
    </div>
    <!-- ç­æ¬¡å›¾ä¾‹ -->
    <div
      v-if="shiftList.length"
      class="mb-4 flex flex-wrap items-center gap-4 rounded-lg bg-white px-4 py-3"
    >
      <span class="text-sm text-gray-500">班次图例:</span>
      <span
        v-for="shift in shiftList"
        :key="shift.id"
        class="inline-flex items-center gap-1.5 text-sm"
      >
        <span
          class="inline-block h-3 w-3 rounded"
          :style="{ backgroundColor: shift.color || '#1677ff' }"
        />
        <span>{{ shift.name }}</span>
        <span class="text-xs text-gray-400"
          >{{ shift.startTime }}~{{ shift.endTime }}</span
        >
      </span>
    </div>
    <!-- æŽ’班网格 -->
    <div class="overflow-auto rounded-lg bg-white">
      <Empty
        v-if="!employees.length && !loading"
        :description="
          loaded
            ? '该部门暂无符合条件的员工,请调整部门或员工状态'
            : '请选择月份、部门后,点击「查询排班」'
        "
        class="py-20"
      />
      <table v-else class="schedule-table w-full text-sm">
        <thead>
          <tr>
            <th class="schedule-check-col bg-gray-100">
              <Checkbox :checked="isAllChecked" @change="handleCheckAll" />
            </th>
            <th class="schedule-name-col bg-gray-100 text-left">员工</th>
            <th
              v-for="vd in viewDays"
              :key="vd.date"
              :class="[
                'schedule-date-col text-center',
                vd.isWeekend ? 'bg-red-50' : 'bg-gray-50',
              ]"
            >
              <div class="font-medium">{{ vd.day }}</div>
              <div class="text-xs text-gray-400">{{ vd.weekLabel }}</div>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="emp in employees" :key="emp.id">
            <td class="schedule-check-col" @click.stop>
              <Checkbox
                :checked="isChecked(emp.id!)"
                @change="handleCheck(emp.id!, $event)"
              />
            </td>
            <td class="schedule-name-col">
              <div class="font-medium">{{ emp.name }}</div>
              <div class="text-xs text-gray-400">{{ emp.employeeNo }}</div>
            </td>
            <td
              v-for="vd in viewDays"
              :key="vd.date"
              class="schedule-cell"
              :class="[vd.isWeekend ? 'bg-red-50/40' : '']"
              @click="openPicker(emp, vd.date)"
            >
              <span
                v-if="getShiftByCell(emp.id!, vd.date)"
                class="shift-chip"
                :title="getShiftByCell(emp.id!, vd.date)!.name"
                :style="{
                  backgroundColor:
                    getShiftByCell(emp.id!, vd.date)!.color || '#1677ff',
                }"
              >
                {{ getShiftByCell(emp.id!, vd.date)!.name }}
              </span>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    <!-- å‘˜å·¥åˆ†é¡µ -->
    <div
      v-if="loaded && employees.length"
      class="mt-4 flex items-center justify-end rounded-lg bg-white p-3"
    >
      <Pagination
        :current="employeePageNo"
        :page-size="employeePageSize"
        :total="employeeTotal"
        show-size-changer
        show-quick-jumper
        :show-total="(total: number) => `共 ${total} äºº`"
        @change="handlePageChange"
      />
    </div>
    <!-- ç­æ¬¡é€‰æ‹©å¼¹çª— -->
    <Modal
      v-model:open="pickerOpen"
      :title="pickerTitle"
      :footer="null"
      :width="440"
    >
      <div class="grid grid-cols-2 gap-3">
        <button
          v-for="shift in shiftList"
          :key="shift.id"
          type="button"
          class="flex cursor-pointer flex-col items-center gap-1 rounded-lg border px-2 py-3 transition-colors hover:border-blue-500"
          :class="[
            isCurrentPick(shift.id!)
              ? 'border-blue-500 bg-blue-50'
              : 'border-gray-200',
          ]"
          @click="pickShift(shift.id!)"
        >
          <span class="inline-flex items-center gap-2 text-sm">
            <span
              class="inline-block h-3 w-3 rounded"
              :style="{ backgroundColor: shift.color || '#1677ff' }"
            />
            <span class="font-medium">{{ shift.name }}</span>
          </span>
          <span class="text-xs text-gray-400"
            >{{ shift.startTime }} ~ {{ shift.endTime }}</span
          >
        </button>
      </div>
      <div class="mt-4 flex items-center justify-between">
        <Button danger @click="pickShift(null)">清除排班(休息)</Button>
        <Button @click="pickerOpen = false">取消</Button>
      </div>
    </Modal>
    <!-- è‡ªåŠ¨æŽ’ç­å¼¹çª— -->
    <Modal v-model:open="autoOpen" title="自动排班" :footer="null" :width="440">
      <div class="space-y-4">
        <div>
          <div class="mb-1.5 text-sm text-gray-500">排班班次</div>
          <Select
            v-model:value="autoShiftId"
            :options="autoShiftOptions"
            placeholder="请选择排班班次"
            style="width: 100%"
          />
        </div>
        <div>
          <div class="mb-1.5 text-sm text-gray-500">休息模式</div>
          <Radio.Group
            v-model:value="autoRestMode"
            :options="REST_MODE_OPTIONS"
            option-type="button"
          />
        </div>
        <div class="text-xs text-gray-400">
          å°†æŒ‰æ‰€é€‰æ¨¡å¼ä¸º{{
            checkedEmployeeIds.size > 0
              ? checkedEmployeeIds.size
              : employees.length
          }}
          åå‘˜å·¥ç”Ÿæˆæ•´æœˆæŽ’班{{
            checkedEmployeeIds.size > 0
              ? '(仅勾选的员工)'
              : '(未勾选时为当前页全部员工)'
          }},生成后可再手动调整。
        </div>
        <div class="flex justify-end gap-2">
          <Button @click="autoOpen = false">取消</Button>
          <Button type="primary" @click="applyAutoSchedule">生成排班</Button>
        </div>
      </div>
    </Modal>
  </Page>
</template>
<style lang="scss" scoped>
.schedule-table {
  width: 100%;
  min-width: max-content;
  border-collapse: separate;
  border-spacing: 0;
  th,
  td {
    height: 44px;
    border: 1px solid #f0f0f0;
  }
  .schedule-date-col {
    width: 52px;
    min-width: 52px;
  }
  .schedule-check-col,
  .schedule-name-col {
    position: sticky;
    z-index: 10;
    background-color: #fafafa;
  }
  .schedule-check-col {
    left: 0;
    width: 40px;
    min-width: 40px;
    padding: 0 8px;
    text-align: center;
  }
  .schedule-name-col {
    left: 40px;
    min-width: 180px;
    padding: 0 12px;
    line-height: 1.3;
  }
  thead .schedule-check-col,
  thead .schedule-name-col {
    z-index: 20;
  }
  .schedule-cell {
    padding: 2px;
    cursor: pointer;
    text-align: center;
    &:hover {
      background-color: #e6f4ff;
    }
  }
  .shift-chip {
    display: inline-block;
    max-width: 100%;
    padding: 1px 6px;
    overflow: hidden;
    color: #fff;
    font-size: 11px;
    text-overflow: ellipsis;
    white-space: nowrap;
    border-radius: 3px;
  }
}
</style>
在上述文件截断后对比
src/views/hrm/shift/data.ts src/views/hrm/shift/index.vue src/views/hrm/shift/modules/form.vue src/views/hrm/transfer/detail/approve/index.vue src/views/im/home/index.vue src/views/mes/dv/machinery/type/components/tree.vue src/views/mes/dv/machinery/type/index.vue src/views/mes/dv/metering/components/index.ts src/views/mes/dv/metering/components/select-dialog.vue src/views/mes/dv/metering/components/select.vue src/views/mes/dv/metering/data.ts src/views/mes/dv/metering/index.vue src/views/mes/dv/metering/modules/form.vue src/views/mes/dv/metering/modules/import-form.vue src/views/mes/dv/meteringcheck/data.ts src/views/mes/dv/meteringcheck/index.vue src/views/mes/dv/meteringcheck/modules/form.vue src/views/mes/md/item/components/product-bom-select-dialog.vue src/views/mes/md/item/components/product-bom-select.vue src/views/mes/md/item/components/select-dialog.vue src/views/mes/md/item/data.ts src/views/mes/md/item/index.vue src/views/mes/md/item/modules/form.vue src/views/mes/md/item/modules/product-media-list.vue src/views/mes/md/item/type/data.ts src/views/mes/md/item/type/index.vue src/views/mes/md/item/type/modules/import-form.vue src/views/mes/md/workstation/index.vue src/views/mes/md/workstation/modules/form.vue src/views/mes/md/workstation/workshop/modules/form.vue src/views/mes/pd/archive/modules/detail.vue src/views/mes/pd/project/detail/index.vue src/views/mes/pd/project/index.vue src/views/mes/pd/project/modules/document-audit-form.vue src/views/mes/pd/project/modules/process-select-modal.vue src/views/mes/pd/project/modules/workflow-timeline.vue src/views/mes/pro/feedback/data.ts src/views/mes/pro/feedback/detail/index.vue src/views/mes/pro/feedback/index.vue src/views/mes/pro/feedback/modules/batch-feedback.vue src/views/mes/pro/feedback/modules/form.vue src/views/mes/pro/feedback/param-state.ts src/views/mes/pro/mps/index.vue src/views/mes/pro/route/data.ts src/views/mes/pro/route/modules/process-form.vue src/views/mes/pro/task/data.ts src/views/mes/pro/task/edit/index.vue src/views/mes/pro/task/index.vue src/views/mes/pro/task/modules/schedule-form.vue src/views/mes/pro/task/modules/task-form.vue src/views/mes/pro/task/modules/task-list.vue src/views/mes/pro/workorder-center/modules/quick-feedback.vue src/views/mes/pro/workorder/components/select-dialog.vue src/views/mes/pro/workorder/components/select.vue src/views/mes/pro/workorder/data.ts src/views/mes/pro/workorder/index.vue src/views/mes/pro/workorder/modules/form.vue src/views/mes/pro/workorder/modules/process-form.vue src/views/mes/pro/workorder/modules/process-list.vue src/views/mes/process-design/parameter/index.vue src/views/mes/process-design/parameter/modules/process-link-dialog.vue src/views/mes/process-design/process/index.vue src/views/mes/process-design/process/modules/param-manage.vue src/views/mes/process-design/route/data.ts src/views/mes/process-design/route/modules/form.vue src/views/mes/process-design/route/modules/process-form.vue src/views/mes/process-design/route/modules/product-form.vue src/views/mes/process-design/route/modules/workbench.vue src/views/mes/qc/batchtrace/data.ts src/views/mes/qc/batchtrace/index.vue src/views/mes/qc/batchtrace/modules/process-trace-list.vue src/views/mes/qc/batchtrace/modules/purchase-source-list.vue src/views/mes/qc/batchtrace/modules/sales-target-list.vue src/views/mes/qc/batchtrace/modules/trace-detail.vue src/views/mes/qc/components/export-range-modal.vue src/views/mes/qc/ipqc/data.ts src/views/mes/qc/ipqc/index.vue src/views/mes/qc/iqc/data.ts src/views/mes/qc/iqc/index.vue src/views/mes/qc/oqc/data.ts src/views/mes/qc/oqc/index.vue src/views/mes/qc/rqc/data.ts src/views/mes/qc/rqc/index.vue src/views/mes/qc/template/data.ts src/views/mes/workbench/components/TaskListPanel.vue src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue src/views/mes/workbench/components/tabs/FeedbackTab.vue src/views/mes/workbench/components/tabs/ProductIssueLineFormModal.vue src/views/mes/workbench/components/tabs/TaskDetailTab.vue src/views/mes/workbench/index.vue src/views/mes/workbench/types.ts src/views/srm/enums.ts src/views/srm/statistics/index.vue src/views/srm/supplier/data.ts src/views/srm/supplier/index.vue src/views/srm/supplier/modules/import-form.vue src/views/srm/supplierApply/data.ts src/views/srm/supplierApply/detail/index.vue src/views/srm/supplierApply/index.vue src/views/srm/supplierApply/modules/form.vue src/views/srm/supplierApply/modules/process-select-modal.vue src/views/srm/supplierCertificate/data.ts src/views/srm/supplierCertificate/index.vue src/views/srm/supplierCertificate/modules/form.vue src/views/srm/tender/data.ts src/views/srm/tender/detail.vue src/views/srm/tender/modules/form.vue src/views/system/dept/data.ts src/views/system/dept/index.vue src/views/system/dept/modules/import-form.vue src/views/system/post/data.ts src/views/system/post/index.vue src/views/system/post/modules/import-form.vue src/views/system/user/data.ts src/views/wls/arrivalnotice/index.vue src/views/wls/arrivalnotice/modules/po-select-dialog.vue src/views/wls/barcode/config/index.vue src/views/wls/barcode/data.ts src/views/wls/itemreceipt/modules/an-select-dialog.vue src/views/wls/materialstock/components/import-form.vue src/views/wls/materialstock/components/index.ts src/views/wls/materialstock/components/stock-adjust-modal.vue src/views/wls/materialstock/index.vue src/views/wls/productissue/components/index.ts src/views/wls/productissue/components/select-dialog.vue src/views/wls/productissue/components/select.vue src/views/wls/productissue/data.ts src/views/wls/productreceipt/data.ts src/views/wls/returnissue/data.ts src/views/wls/returnissue/modules/form.vue src/views/wls/returnissue/modules/line-form.vue src/views/wls/returnissue/modules/line-list.vue src/views/wls/returnvendor/data.ts src/views/wls/returnvendor/modules/detail-form.vue src/views/wls/returnvendor/modules/form.vue src/views/wls/stocktaking/plan/data.ts src/views/wls/stocktaking/plan/modules/param-form.vue src/views/wls/warehouse/area/data.ts src/views/wls/warehouse/data.ts src/views/wls/warehouse/location/data.ts vite.config.ts