liu
2026-10-26 7763f23f3224a0f609c4c483351edd656b0dfb26
Merge remote-tracking branch 'origin/dev_pro2.0' into dev_pro2.0
已添加42个文件
已删除12个文件
已修改379个文件
15736 ■■■■■ 文件已修改
.env 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.development 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
DESIGN.md 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HSC.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
PRODUCT.md 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hm.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
index.html 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
internal/vite-config/src/plugins/inject-app-loading/default-loading.html 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/images/auth/herbal-courtyard.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/bi-realtime-table-qa.mjs 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/check-analytics-permissions.cjs 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/check-logout-browser.cjs 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/check-logout.cjs 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/login-theme-live-qa.cjs 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/one-click-schedule-qa.mjs 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/spare-management-qa.mjs 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/adapter/form.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/adapter/vxe-table.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/agent/conversation.ts 641 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ai/model/model/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bi/chart-config.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm/autoApprove/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/core/auth.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/contract/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/customer/index.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/energy/index.ts 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/resignation/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/transfer/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/item/agent.ts 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/meteringcheck/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/spare/index.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/home/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/md/client/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pd/archive/index.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pd/document-audit/index.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/mps/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/index.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/process/index.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/product/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/task/index.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/process/index.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/date-filter.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/indicatorresult/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ipqc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/iqc/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ledger/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/oqc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/rqc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/batch/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/packages/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productissue/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/returnsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/salesnotice/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/request.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/post/index.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/user/index.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/batch/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/packages/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/productissue/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/productsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/returnsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/salesnotice/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/crm-customer-select-dialog.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/crm-customer-select.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/description/description.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/import-modal/import-modal.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/import-modal/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/operate-log/operate-log.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/operate-log/typing.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/report-evaluator.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/table-action/table-action.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/cache/index.d.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/cache/index.mjs 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/color/index.d.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/color/index.mjs 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/constants/index.d.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/constants/index.mjs 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/global-state.d.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/global-state.mjs 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/store.d.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/store.mjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/utils/index.d.ts 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/utils/index.mjs 1775 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/shadcn-ui/src/components/input-password/input-password.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-ai-enum.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/common-ui/src/ui/authentication/login.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/hooks/src/use-app-config.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/hooks/src/use-design-tokens.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/request/src/request-client/preset-interceptors.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/request/src/request-client/request-client.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/stores/src/setup.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/ai.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/audit-policy.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/company-config.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/audit-ui-policy.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/contract-document.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/import-result.ts 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/rangePickerProps.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/agent/dataEntry/components/TaskCenterDrawer.vue 298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/agent/dataEntry/index.vue 1523 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/model/model/data.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/model/model/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/data.ts 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/index.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/agent-import.vue 610 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/import-form.vue 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/chart/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/data-source/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/chart-options.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/modules/ChartCard.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/modules/RealtimeTable.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/warehouse/index.vue 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/oa/leave/create.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/index.vue 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/contract-audit-list.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/contract-remind-list.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/customer-follow-list.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/customer-put-pool-remind-list.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/customer-today-contact-list.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/receivable-audit-list.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/receivable-plan-remind-list.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/business/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/business/detail/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/business/detail/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/business/status/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/clue/data.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/clue/detail/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/clue/detail/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contact/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contact/detail/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contact/detail/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/components/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/data.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/approve/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/index.vue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/data.ts 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/modules/distribute-form.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/limitConfig/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/modules/import-form.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/pool/data.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/pool/index.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/followup/data.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/detail/approve/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/detail/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/detail/index.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/permission/modules/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/product/category/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/product/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/product/detail/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/components/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/detail/approve/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/detail/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/detail/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/plan/components/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/plan/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/plan/detail/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/plan/detail/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/ocr-upload.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/analysis/data.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/customer/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/customer/index.vue 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/funnel/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/portrait/chartOptions.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/portrait/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/portrait/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/rank/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/statistics/rank/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/alert-panel.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/biz-cards.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/kpi-cards.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/production-trend.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/use-home-access.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/alert/index.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/analysis/index.vue 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/record/index.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/shared.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/account/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/detail/approve/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/receipt/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/product/category/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/product/product/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/product/unit/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/in/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/detail/approve/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/index.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/detail/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/detail/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/index.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/return/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/customer/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/detail/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/form.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/print.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/out/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/return/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/stock/check/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/stock/in/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/stock/move/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/stock/out/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/stock/record/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/stock/warehouse/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/stock/warehouseMapping/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/exception/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/exception/detail/approve/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/exception/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/holiday/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/modules/import-form.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/detail/approve/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/detail/approve/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/resignation/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/resignation/detail/approve/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/resignation/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/employee-social-security/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/leave-type-config/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/payment/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/social-security-scheme/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/structure/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/transfer/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/transfer/detail/approve/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/rtcStore.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/uiStore.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/plan/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/team/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/checkplan/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/checkrecord/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/data.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/modules/check-record-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/modules/import-form.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/modules/mainten-record-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/modules/repair-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/machinery/type/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/maintenrecord/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/metering/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/meteringcheck/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/repair/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/repair/modules/form.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare-record/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/modules/action.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/modules/form.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/modules/records.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/subject/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/modules/alert-panel.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/home/modules/kpi-cards.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/autocode/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/client/components/select.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/client/data.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select-dialog.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/data.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/modules/import-form.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/type/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/workstation/components/workshop-select.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/workstation/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/workstation/workshop/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/archive/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/archive/modules/detail.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/detail/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/detail/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/document-audit-form.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/document-audit-list.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/task/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/version-change/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/andon/record/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/card/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/card/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/card/modules/form.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/batch-input-dialog.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/modules/form.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/process/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/output-attribution.vue 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/data.ts 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/index.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/material-input-list.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-form.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-list.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-bom-list.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-form.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-list.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/route-configurator.vue 425 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/index.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/modules/auto-schedule.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/index.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-form.vue 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-list.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workrecord/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/parameter/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/parameter/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/data.ts 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/index.vue 66 ●●●●● 补丁 | 查看 | 原始文档 | 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 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/template/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/template/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/data.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/trace-detail.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/ai-assistant.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/ai-suggest.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/defect/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicator/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicatorresult/components/data.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicatorresult/components/form.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicatorresult/components/list.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/data.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/data.ts 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ledger/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ncr/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/data.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/pendinginspect/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/instance/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/rqc/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/template/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/tm/tool/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/tm/tool/type/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/ProductIssueFormModal.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/ProductIssueTab.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/ReturnIssueFormModal.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/ReturnIssueTab.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/detail/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/dept/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/dict/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/loginlog/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/operatelog/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/post/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/post/modules/import-form.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/role/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/user/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/user/modules/import-form.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/components/printer-label.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/batch/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/itemreceipt/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscreceipt/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/outsourceissue/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/outsourcereceipt/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/outsourcereceipt/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/packages/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/modules/line-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/modules/line-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/sn/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transaction/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transfer/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/area/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/location/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/md/item/brand/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/md/item/category/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/md/warehouse/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/check/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/check/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/check/modules/print.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/movement/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/movement/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/movement/modules/print.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/receipt/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/receipt/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/receipt/modules/print.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/shipment/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/shipment/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wms/order/shipment/modules/print.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
trt.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -19,6 +19,9 @@
# æ–‡æ¡£åœ°å€çš„开关
VITE_APP_DOCALERT_ENABLE=true
# AI åŠŸèƒ½æ€»å¼€å…³ï¼šfalse æ—¶éšè—æ‰€æœ‰ AI å…¥å£ï¼ˆAI æ¨¡å—菜单、OCR、预测、智能判定、智能体导入)
VITE_APP_AI_ENABLE=false
# ç™¾åº¦ç»Ÿè®¡
VITE_APP_BAIDU_CODE = e98f2eab6ceb8688bc6d8fc5332ff093
.env.development
@@ -11,6 +11,9 @@
VITE_UPLOAD_TYPE=server
# æ˜¯å¦æ‰“å¼€ devtools,true ä¸ºæ‰“开,false ä¸ºå…³é—­
VITE_DEVTOOLS=false
# AI åŠŸèƒ½æ€»å¼€å…³ï¼šfalse æ—¶éšè—æ‰€æœ‰ AI å…¥å£ï¼ˆAI æ¨¡å—菜单、OCR、预测、智能判定、智能体导入)
VITE_APP_AI_ENABLE=true
# æ˜¯å¦æ³¨å…¥å…¨å±€loading
VITE_INJECT_APP_LOADING=true
DESIGN.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
---
name: è¯å ‚主题
description: æœ±çº¢ã€æš–金与宣纸白构成的登录场景及应用主题
colors:
  primary: "#a51d24"
  primary-dark: "hsl(357 64% 73%)"
  paper: "hsl(40 50% 99%)"
  paper-deep: "hsl(37 33% 95%)"
  ink: "hsl(27 16% 17%)"
  border: "hsl(33 25% 86%)"
  dark-background: "hsl(26 16% 12%)"
  dark-card: "hsl(26 15% 14%)"
  dark-foreground: "hsl(35 30% 90%)"
  frame-gold: "#cda878"
typography:
  display:
    fontFamily: '"STKaiti", "KaiTi", "FangSong", serif'
    fontWeight: 700
  body:
    fontFamily: "var(--font-family, system-ui, sans-serif)"
rounded:
  control: "9px"
components:
  login-button:
    backgroundColor: "{colors.primary}"
    textColor: "#ffffff"
    rounded: "{rounded.control}"
---
# Design System: è¯å ‚主题
## Overview
**Creative North Star: "宣纸上的药堂"**
以已确认的朱红、暖金、宣纸白与药堂庭院参考为依据。登录场景用药材与建筑承载气氛,表单保持清晰;应用内部沿用现有组件与布局,通过全局语义色传递同一视觉身份。登录页的堂训、装饰框与庭院构图不推广到业务页面。
## Colors
朱红承担主要操作与选中态,暖金用于登录场景的细框和装饰;暖白与墨色承担日常阅读。`src/preferences.ts` å®šä¹‰é»˜è®¤ä¸»è‰²ï¼Œ`src/styles/herbal-theme.css` å®šä¹‰æ˜Žæš—中性色;主色色阶由偏好系统生成。
**The ç”¨æˆ·åå¥½ Rule.** é¦–次主题升级迁移到默认朱红,此后保留用户选择的主色与明暗模式。亮朱砂暗色主色覆盖只在当前主色等于默认朱红时启用。迁移细节与存储限制见[登录主题记录](../docs/design/login-herbal-theme.md)。
## Typography
正文继承应用字体变量与系统无衬线回退,保持表单和业务数据可读。公司名称、堂训采用本机楷体/仿宋回退,不下载字体;不同操作系统的字形会有差异。登录标题使用正文体系的粗体,避免把装饰字体用于输入内容。
## Layout
业务界面保留既有布局密度与组件结构。登录桌面采用左侧堂训、右侧表单,宽屏限制表单宽度;小于等于 900px æ”¹ä¸ºå•列表单并隐藏堂训,小于等于 540px æ”¶ç´§è¾¹è·ã€å…è®¸å“ç‰ŒåŒºåŸŸæ¢è¡Œã€‚语言和主题工具保留在页首。短桌面视口降低表单最小高度和内边距。
## Elevation & Depth
庭院图片位于登录内容下层;浅色表单用接近不透明的纸白底与柔和暖色阴影提供阅读层。暗色降低图片亮度与饱和度,表单改用深棕底。应用内部依赖现有卡片、浮层语义色,不追加场景装饰。
## Shapes
登录输入框与主按钮使用轻圆角;表单外壳使用更宽的圆角,暖金细线和四角纹样仅属于认证场景。框线与纹样为装饰,不承载交互。
## Components
认证布局继续渲染真实路由,保留现有 `AuthenticationLogin`、表单校验、加载态、验证码与认证 store è°ƒç”¨ã€‚输入支持账号/当前密码自动填充;可见焦点环明确定位键盘操作。密码切换为明文后仍保留显隐按钮的右侧空间(44px),避免文字与按钮重叠。暗色工具条采用深色底。标题旁使用无文字几何 SVG çº¹æ ·ã€‚
**The å…¬å¸èº«ä»½ Rule.** å…¬å¸åç§°ã€Logo、登录标题和描述仍读取现有配置。药堂背景与装饰属于主题,不能替代公司 Logo;未配置公司标志时隐藏框架示例 Logo。
## Do's and Don'ts
- ä½¿ç”¨è¯­ä¹‰è‰²ä¸ŽçŽ°æœ‰åå¥½ç³»ç»Ÿæ‰©å±•ä¸šåŠ¡é¡µé¢ï¼›ä¸šåŠ¡çŠ¶æ€è‰²ä¿ç•™åŽŸæœ‰å«ä¹‰ã€‚
- ä¿ç•™å…¬å¸åŠ¨æ€é…ç½®ã€çœŸå®žè®¤è¯é“¾è·¯ä¸Žç”¨æˆ·åŽç»­ä¸»é¢˜åå¥½ã€‚
- ä¸æŠŠèƒŒæ™¯ä¸­çš„药堂场景或几何装饰解释为企业认证、商标或产品资质。
- ä¸åœ¨ç”Ÿæˆçš„背景中绘制文字、Logo、表单或其他 UI。
- ä¸æŠŠ mock æˆªå›¾éªŒè¯æè¿°ä¸ºçœŸå®žåŽç«¯ç™»å½•成功。
HSC.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=HSC"
set "REMOTE_USER=root"
set "REMOTE_HOST=36.213.150.237"
set "REMOTE_PORT=65022"
set "PASSWORD=Sh@1008600"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/data/vue/mom/hsc/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
PRODUCT.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Operating Context
现有 MOM ç®¡ç†åŽå°ã€‚此次能耗功能面向青海的一家北京同仁堂企业,主要产品包含燕窝及滋补品原浆(用户提供)。
## Capabilities and Constraints
能耗管理是独立模块,手动按天录入水、电、蒸汽使用信息,提供增删改查和数据分析两个子菜单。用户已确认按天录入。区域、产品类别、用途均为本模块信息,不关联其他业务模块。分析支持按日/月汇总。
## Open Decisions
企业正式名称、实际区域名称与能源单价未提供;区域建议仅用于录入提示,价格由用户填写。
company.config.ts
@@ -63,12 +63,30 @@
    name: '康意智能',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9004/admin-api',
    apiUrl: 'http://36.213.150.237:9006/admin-api',
  },
  CHDL: {
    name: '长辉电力',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9006/admin-api',
    apiUrl: 'http://36.213.150.237:9004/admin-api',
  },
  HM: {
    name: '惠牧',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9013/admin-api',
  },
  TRT: {
    name: '北京同仁堂',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9015/admin-api',
  },
  HSC: {
    name: '好水川食品有限公司',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9540/admin-api',
  },
};
hm.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=HM"
set "REMOTE_USER=root"
set "REMOTE_HOST=36.213.150.237"
set "REMOTE_PORT=65022"
set "PASSWORD=Sh@1008600"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/data/vue/mom/hm/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
index.html
@@ -11,8 +11,10 @@
      name="viewport"
      content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
    />
    <!-- ç”± vite æ³¨å…¥ VITE_APP_TITLE å˜é‡ï¼Œåœ¨ .env æ–‡ä»¶å†…配置 -->
    <title>%VITE_APP_TITLE%</title>
    <!-- é¦–屏标题固定为中性文案,不要把客户品牌编译进产物(否则换个环境部署就会"串品牌")。
         çœŸå®žå“ç‰Œç”±è¿è¡Œæ—¶ä¸‹å‘:系统管理 â†’ åå¥½è®¾ç½® â†’ å…¬å¸ä¿¡æ¯ï¼ˆåŽç«¯ system_company_config),
         å¯åŠ¨åŽè¦†ç›– document.title,见 src/utils/company-config.ts -->
    <title>工业AI智造平台</title>
    <link rel="icon" href="/favicon.ico" />
    <script>
      var HM_ID = '%VITE_APP_BAIDU_CODE%';
internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html
@@ -103,5 +103,6 @@
</style>
<div class="loading" id="__app-loading__">
  <span class="dot"><i></i><i></i><i></i><i></i></span>
  <div class="title">%VITE_APP_TITLE%</div>
  <!-- ä¸­æ€§æ–‡æ¡ˆï¼šåŠ è½½é¡µæ—©äºŽ JS æ‰§è¡Œï¼Œæ‹¿ä¸åˆ°è¿è¡Œæ—¶çš„公司信息,不能写死客户品牌 -->
  <div class="title">正在加载,请稍候…</div>
</div>
internal/vite-config/src/plugins/inject-app-loading/default-loading.html
@@ -109,5 +109,6 @@
</style>
<div class="loading" id="__app-loading__">
  <div class="loader"></div>
  <div class="title">%VITE_APP_TITLE%</div>
  <!-- ä¸­æ€§æ–‡æ¡ˆï¼šåŠ è½½é¡µæ—©äºŽ JS æ‰§è¡Œï¼Œæ‹¿ä¸åˆ°è¿è¡Œæ—¶çš„公司信息,不能写死客户品牌 -->
  <div class="title">正在加载,请稍候…</div>
</div>
public/images/auth/herbal-courtyard.png
scripts/bi-realtime-table-qa.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(path.join(root, 'package.json'));
const { parse, compileScript } = require('vue/compiler-sfc');
const ts = require('typescript');
const { chromium } = require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const source = fs.readFileSync(path.join(root, 'src/views/bi/dashboard/modules/RealtimeTable.vue'), 'utf8');
const { descriptor } = parse(source);
const script = compileScript(descriptor, { id: 'qa', inlineTemplate: true });
const module = ts.transpileModule(script.content, {
  compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext },
}).outputText.replace(/from ['"]vue['"]/g, 'from "/vue.js"');
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>BI æ»šåŠ¨éªŒè¯</title>
<style>body{background:#020817;color:#ebf0fc;font-family:Microsoft YaHei,sans-serif;margin:24px}h1{font-size:18px}main{max-width:1100px;margin:auto}.card{padding:16px;background:#0a1834;border:1px solid #153554;border-radius:8px} ${descriptor.styles.map(s => s.content).join('\n')}</style>
<main><h1>最近入库记录</h1><div class="card"><div id="app"></div></div></main>
<script type="module">
import {createApp, h, ref} from '/vue.js';import Table from '/table.js';
const rows=ref([]), height=ref(180), show=ref(true); const makeRows=n=>Array.from({length:n},(_,i)=>({'入库单号':'RK-'+String(i+1).padStart(4,'0'),'产品名称':'验证产品 '+(i+1),'仓库':'南通仓','数量':100+i}));
rows.value=makeRows(20); window.qa={setRows:n=>rows.value=makeRows(n), refresh:()=>rows.value=[...rows.value], setHeight:n=>height.value=n, hide:()=>show.value=false};
createApp({render:()=>show.value?h(Table,{rows:rows.value,maxHeight:height.value,speed:30}):null}).mount('#app');
</script></html>`;
const vueFile = path.join(path.dirname(require.resolve('vue/package.json')), 'dist/vue.esm-browser.js');
const server = http.createServer((request, response) => {
  response.setHeader('Content-Type', request.url === '/' ? 'text/html; charset=utf-8' : 'text/javascript; charset=utf-8');
  response.end(request.url === '/table.js' ? module : request.url === '/vue.js' ? fs.readFileSync(vueFile) : html);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser;
const errors = [];
const output = path.join(root, '../scripts/bi-realtime-scroll-20261014');
fs.mkdirSync(output, { recursive: true });
try {
  browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
  const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
  page.on('pageerror', error => errors.push(error.message));
  await page.goto(`http://127.0.0.1:${server.address().port}`);
  await page.waitForFunction(() => document.querySelector('.table-track')?.getAnimations().length === 1);
  const offset = () => page.locator('.table-track').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m42);
  const top = await page.locator('.header-table').evaluate(el => el.getBoundingClientRect().top);
  const first = await offset();
  await page.waitForTimeout(500);
  assert.ok((await offset()) < first - 8, 'Rows must move upwards');
  assert.equal(await page.locator('.header-table').evaluate(el => el.getBoundingClientRect().top), top, 'Header must stay fixed');
  await page.locator('.realtime-table').hover();
  const paused = await offset();
  await page.waitForTimeout(250);
  assert.ok(Math.abs((await offset()) - paused) < 1, 'Hover must pause');
  await page.mouse.move(0, 0);
  await page.waitForTimeout(200);
  assert.ok((await offset()) < paused - 3, 'Mouse leave must resume');
  const refreshOffset = await offset();
  await page.evaluate(() => window.qa.refresh());
  await page.waitForTimeout(100);
  assert.ok((await offset()) < refreshOffset, 'Refresh must preserve scroll position');
  await page.locator('.table-track').evaluate(el => {
    const animation = el.getAnimations()[0];
    animation.currentTime = Number(animation.effect.getTiming().duration) - 200;
  });
  await page.waitForTimeout(400);
  assert.ok((await offset()) > -20, 'Loop must wrap seamlessly');
  await page.locator('.realtime-table').focus();
  const focused = await offset();
  await page.waitForTimeout(100);
  assert.ok(Math.abs((await offset()) - focused) < 1, 'Focus must pause');
  await page.keyboard.press('ArrowDown');
  assert.ok((await offset()) < focused - 35, 'Keyboard must advance rows');
  await page.evaluate(() => document.activeElement.blur());
  await page.screenshot({ path: path.join(output, 'desktop.png'), fullPage: true });
  await page.setViewportSize({ width: 390, height: 844 });
  await page.waitForTimeout(150);
  assert.ok(await page.locator('.table-track').evaluate(el => el.getAnimations().length === 1), 'Resize must keep one animation');
  await page.screenshot({ path: path.join(output, 'mobile.png'), fullPage: true });
  await page.evaluate(() => window.qa.setRows(2));
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.table-track').evaluate(el => el.getAnimations().length), 0, 'Short table must not scroll');
  assert.equal(await page.locator('table[aria-hidden="true"]').count(), 0, 'Short table must not duplicate rows');
  await page.evaluate(() => window.qa.setRows(30));
  await page.waitForFunction(() => document.querySelector('.table-track').getAnimations().length === 1);
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.table-track').evaluate(el => el.getAnimations().length), 0, 'Reduced motion must disable autoplay');
  assert.equal(await page.locator('.table-viewport').evaluate(el => getComputedStyle(el).overflowY), 'auto', 'Reduced motion must allow manual scroll');
  await page.emulateMedia({ reducedMotion: 'no-preference' });
  await page.waitForFunction(() => document.querySelector('.table-track').getAnimations().length === 1);
  await page.evaluate(() => window.qa.setHeight(2000));
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.table-track').evaluate(el => el.getAnimations().length), 0, 'Larger viewport must stop scrolling when rows fit');
  await page.evaluate(() => window.qa.hide());
  await page.waitForTimeout(100);
  assert.equal(await page.evaluate(() => document.getAnimations().length), 0, 'Unmount must clean up animations');
  assert.deepEqual(errors, []);
  const result = { passed: true, checks: ['continuous scroll', 'fixed header', 'hover pause/resume', 'refresh continuity', 'seamless loop', 'keyboard navigation', 'responsive resize', 'short data', 'reduced motion', 'unmount cleanup'], screenshots: ['desktop.png', 'mobile.png'] };
  fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
  console.log(JSON.stringify(result));
} finally {
  await browser?.close();
  server.close();
}
scripts/check-analytics-permissions.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const assert = require('node:assert/strict');
const { parse, compileTemplate } = require('vue/compiler-sfc');
const root = path.join(__dirname, '../src/views/dashboard/analytics');
function evaluate(source, mocks) {
  const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
  const module = { exports: {} };
  new Function('require', 'module', 'exports', 'defineOptions', 'defineProps', 'defineEmits', js)(
    (id) => mocks[id] || {}, module, module.exports, () => {}, () => ({ repairActiveCount: 0 }), () => () => {});
  return module.exports;
}
const vue = { computed: (getter) => ({ get value() { return getter(); } }), ref: (value) => ({ value }) };
async function scenario(codes, names, expected) {
  const store = { accessRoutes: [{ name: 'Parent', children: names.map((name) => ({ name })) }] };
  const home = evaluate(fs.readFileSync(path.join(root, 'use-home-access.ts'), 'utf8'), {
    vue, '@vben/stores': { useAccessStore: () => store },
    '@vben/access': { useAccess: () => ({ hasAccessByCodes: (requested) => requested.some((code) => codes.includes(code)) }) },
  });
  const calls = [];
  const mocks = { '../use-home-access': home, './use-home-access': home,
    'vue-router': { useRouter: () => ({ push: () => {} }) }, './data': { defaultSummary: {} } };
  for (const [id, method] of [
    ['#/api/mes/home', 'getHomeSummary'], ['#/api/mes/home', 'getProductionTrend'],
    ['#/api/mes/home', 'getWorkOrderStatusDistribution'], ['#/api/erp/purchase/order', 'getPurchaseOrderPage'],
    ['#/api/erp/sale/order', 'getSaleOrderPage'], ['#/api/mes/wm/materialstock', 'getMaterialStockPage'],
    ['#/api/bpm/task', 'getTaskTodoPage'],
  ]) {
    mocks[id] ||= {};
    mocks[id][method] = async () => { calls.push(method); return method.startsWith('getHome') ? {} : method.endsWith('Page') ? { total: 3 } : []; };
  }
  mocks['@vben/plugins/echarts'] = { useEcharts: () => ({ renderEcharts: () => {} }) };
  for (const file of ['index.vue', 'modules/biz-cards.vue', 'modules/production-trend.vue', 'modules/alert-panel.vue']) {
    const mounted = [];
    mocks.vue = { ...vue, onMounted: (fn) => mounted.push(fn) };
    const { descriptor } = parse(fs.readFileSync(path.join(root, file), 'utf8'));
    evaluate(descriptor.scriptSetup.content, mocks);
    mounted.forEach((fn) => fn());
  }
  await new Promise((resolve) => setImmediate(resolve));
  assert.deepEqual(calls.sort(), expected.sort());
  const access = home.useHomeAccess();
  store.accessRoutes = [{ name: 'Forbidden', meta: { menuVisibleWithForbidden: true }, children: [{ name: 'Child' }] }];
  assert.equal(access.canNavigate('Forbidden'), false);
  assert.equal(access.canNavigate('Child'), false);
}
(async () => {
  await scenario([], [], []);
  await scenario(['erp:purchase-order:query'], ['ErpPurchaseOrder'], ['getPurchaseOrderPage']);
  await scenario(['erp:purchase-order:query'], [], []);
  await scenario(['mes:home:query'], [], ['getHomeSummary', 'getProductionTrend', 'getWorkOrderStatusDistribution']);
  await scenario(['*:*:*'], ['ErpPurchaseOrder', 'ErpSaleOrder', 'MesWmMaterialStock', 'BpmTaskTodo'],
    ['getHomeSummary', 'getProductionTrend', 'getWorkOrderStatusDistribution', 'getPurchaseOrderPage', 'getSaleOrderPage', 'getMaterialStockPage', 'getTaskTodoPage']);
  for (const file of ['index.vue', ...fs.readdirSync(path.join(root, 'modules')).filter((f) => f.endsWith('.vue')).map((f) => `modules/${f}`)]) {
    const { descriptor, errors } = parse(fs.readFileSync(path.join(root, file), 'utf8'));
    assert.equal(errors.length, 0, file);
    const result = compileTemplate({ source: descriptor.template.content, filename: file, id: file });
    assert.equal(result.errors.length, 0, `${file}: ${result.errors}`);
  }
  console.log('PASS: 5 permission scenarios, forbidden routes, and all analytics Vue templates');
})();
scripts/check-logout-browser.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
// Real user dropdown, confirmation and router; all APIs simulated.
const assert=require('node:assert/strict'),fs=require('fs'),path=require('path');
const {chromium}=require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
(async()=>{
 const server=await(await import('vite')).createServer({root:path.resolve(__dirname,'..'),server:{host:'127.0.0.1',port:5183}});await server.listen();
 const browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});
 const page=await browser.newPage({viewport:{width:1440,height:950},reducedMotion:'reduce'});const errors=[];let logoutCalls=0;let releaseLogout;let logoutHeader;
 page.on('pageerror',e=>errors.push(e.message));
 await page.route('**/admin-api/**',async route=>{
   const url=route.request().url();
   if(url.includes('/system/auth/logout')){logoutCalls++;logoutHeader=route.request().headers().authorization;await new Promise(r=>releaseLogout=r);}
   const data=url.includes('company-config')?{companyName:'退出验证平台'}:url.includes('count')?0:url.includes('list')?[]:null;
   await route.fulfill({contentType:'application/json',body:JSON.stringify({code:0,data,msg:''})}).catch(()=>{});
 });
 try{
  await page.goto('http://127.0.0.1:5183/auth/login',{waitUntil:'domcontentloaded'});await page.getByRole('heading',{name:'欢迎回来'}).waitFor();
  await page.waitForFunction(()=>!document.querySelector('#__app-loading__')||getComputedStyle(document.querySelector('#__app-loading__')).opacity==='0');
  await page.evaluate(async()=>{
   const stores=await import('/src/packages/stores/src/index.ts'),{router}=await import('/src/router/index.ts'),{h}=await import('/node_modules/.vite/deps/vue.js');
   stores.useAccessStore().setAccessToken('qa-session-token');stores.useAccessStore().setRefreshToken('qa-refresh-token');stores.useAccessStore().setIsAccessChecked(true);
   stores.useUserStore().setUserInfo({userId:'1',username:'logout-qa',nickname:'退出验证用户',avatar:''});
   router.addRoute('Root',{name:'LogoutQa',path:'/qa-logout-session',meta:{title:'退出验证工作区'},component:{render(){return h('div','退出验证工作区')}}});
   await router.push('/qa-logout-session');
  });
  await page.locator('[id="__vben_main_content"]').getByText('退出验证工作区',{exact:true}).waitFor({timeout:60000});
  const avatars=page.locator('button[aria-haspopup="menu"]').filter({has:page.locator('.size-8')}).first();
  await avatars.click();
  await page.getByRole('menuitem',{name:/退出登录/}).click();
  const dialog=page.getByRole('dialog');await dialog.waitFor();
  const started=Date.now();await dialog.getByRole('button',{name:/^ç¡®\s*[认定]$/}).click();
  await page.waitForURL('**/auth/login',{timeout:5000});await page.getByRole('heading',{name:'欢迎回来'}).waitFor();
  const elapsed=Date.now()-started;
  const state=await page.evaluate(async()=>{const s=await import('/src/packages/stores/src/index.ts');return{access:s.useAccessStore().accessToken,refresh:s.useAccessStore().refreshToken,user:s.useUserStore().userInfo}});
  assert.equal(state.access,null);assert.equal(state.refresh,null);assert.equal(state.user,null);
  assert.equal(logoutCalls,1);assert.equal(logoutHeader,'Bearer qa-session-token');assert.equal(typeof releaseLogout,'function','Server logout must still be pending when login page appears');
  releaseLogout();await page.waitForTimeout(100);
  const result={passed:true,scope:'Real application user dropdown, confirmation, stores and routing; simulated authenticated session and pending backend, no real account login',checks:['click logout and confirm','navigate without refresh while server pending','access and refresh token cleared','user info cleared','server revoke uses original token'],elapsedMs:elapsed,errors};
  assert.deepEqual(errors,[]);
  const out=path.resolve(__dirname,'../../scripts/logout-fix-20261015');fs.mkdirSync(out,{recursive:true});fs.writeFileSync(path.join(out,'browser-result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
 }catch(error){console.error(JSON.stringify({message:error.message,errors,body:(await page.locator('body').innerText()).slice(0,1800)}));throw error;}
 finally{releaseLogout?.();await browser.close();await server.close();}
})();
scripts/check-logout.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const vue = require('vue');
const pinia = require('pinia');
const root = path.join(__dirname, '..');
function load(file, mocks = {}) {
  const source = fs.readFileSync(path.join(root, file), 'utf8')
    .replaceAll('import.meta.hot', 'undefined')
    .replaceAll('import.meta.env', '({ DEV: true })');
  const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText;
  const module = { exports: {} };
  new Function('require', 'module', 'exports', 'localStorage', js)((id) => {
    if (id in mocks) return mocks[id];
    if (id === 'vue') return vue;
    if (id === 'pinia') return pinia;
    throw new Error(`Unexpected import ${id} in ${file}`);
  }, module, module.exports, {});
  return module.exports;
}
const constants = load('src/views/im/utils/constants.ts');
async function scenario(apiFails, resetFails, redirect) {
  const app = vue.createApp({});
  const setup = load('src/packages/stores/src/setup.ts', {
    'secure-ls': class {}, 'pinia-plugin-persistedstate': { createPersistedState: () => () => {} },
  });
  const instance = await setup.initStores(app, { namespace: 'logout-test' });
  pinia.setActivePinia(instance);
  const company = load('src/store/company-config.ts', {
    '#/api/system/company-config': {}, '#/utils/company-config': {},
  }).useCompanyConfigStore();
  company.companyName = 'Test company';
  company.logoUrl = '/test-logo.png';
  company.loginTitle = 'Test';
  company.loginDescription = 'Test description';
  const ui = load('src/views/im/home/store/uiStore.ts', { '../../utils/constants': constants }).useImUiStore();
  ui.userInfoCard.show = true;
  ui.userInfoCard.user = { id: 1 };
  ui.contextMenu.items = [{ key: 'private', name: 'private' }];
  ui.contextMenu.onSelect = () => {};
  ui.nextUnreadJumpNonce = 3;
  const rtc = load('src/views/im/home/store/rtcStore.ts', {
    '../../utils/constants': constants, '#/views/im/utils/auth': {}, './friendStore': {},
    './groupStore': { useGroupStore: () => ({ markGroupActiveCallLoaded: () => {} }) },
  }).useRtcStore();
  rtc.stage = constants.ImRtcCallStage.RUNNING;
  rtc.call = { id: 1 };
  rtc.setGroupCall({ groupId: 1, room: 'test', joinedUserIds: [1], inviteeIds: [] });
  const accessModule = load('src/packages/stores/src/modules/access.ts');
  const userModule = load('src/packages/stores/src/modules/user.ts');
  const access = accessModule.useAccessStore();
  access.setAccessToken('old-access');
  access.setRefreshToken('old-refresh');
  access.setIsAccessChecked(true);
  access.setLoginExpired(true);
  userModule.useUserStore().setUserInfo({ userId: 1 });
  if (resetFails) pinia.defineStore('broken-business-store', () => ({ count: vue.ref(1) }))();
  const navigations = [];
  const apiCalls = [];
  const auth = load('src/store/auth.ts', {
    'vue-router': { useRouter: () => ({ currentRoute: vue.ref({ fullPath: '/analytics?tab=1' }), replace: async (to) => navigations.push(to) }) },
    '@vben/constants': { LOGIN_PATH: '/auth/login' }, '@vben/preferences': {},
    '@vben/stores': { ...setup, ...accessModule, ...userModule }, 'ant-design-vue': {}, '#/locales': {},
    '#/store/audit-policy': { automaticApproval: vue.ref(false), loadAuditPolicy: async () => {} },
    '#/api': { logoutApi: async (token) => { apiCalls.push(token); if (apiFails === 'pending') return new Promise(() => {}); if (apiFails) throw new Error('offline'); } },
  }).useAuthStore();
  const errors = [];
  const originalError = console.error;
  console.error = (...args) => errors.push(args);
  try { await auth.logout(redirect); } finally { console.error = originalError; }
  assert.deepEqual(apiCalls, ['old-access']);
  assert.equal(navigations.length, 1);
  assert.deepEqual(navigations[0], { path: '/auth/login', query: redirect ? { redirect: encodeURIComponent('/analytics?tab=1') } : {} });
  assert.equal(access.accessToken, null);
  assert.equal(access.refreshToken, null);
  assert.equal(access.isAccessChecked, false);
  assert.equal(access.loginExpired, false);
  assert.equal(userModule.useUserStore().userInfo, null);
  assert.equal(company.companyName, '');
  assert.equal(company.logoUrl, '');
  assert.equal(company.loginTitle, '');
  assert.equal(company.loginDescription, '');
  assert.equal(ui.userInfoCard.show, false);
  assert.equal(ui.userInfoCard.user, null);
  assert.deepEqual(ui.contextMenu.items, []);
  assert.equal(ui.contextMenu.onSelect, null);
  assert.equal(ui.nextUnreadJumpNonce, 0);
  assert.equal(rtc.stage, constants.ImRtcCallStage.IDLE);
  assert.equal(rtc.call, null);
  assert.equal(rtc.getGroupCall(1), undefined);
  assert.equal(errors.length, resetFails ? 1 : 0);
}
async function sessionFixture(api) {
  const app=vue.createApp({});
  const setup=load('src/packages/stores/src/setup.ts',{'secure-ls':class{},'pinia-plugin-persistedstate':{createPersistedState:()=>()=>{}}});
  pinia.setActivePinia(await setup.initStores(app,{namespace:'logout-races'}));
  const accessModule=load('src/packages/stores/src/modules/access.ts'),userModule=load('src/packages/stores/src/modules/user.ts');
  const access=accessModule.useAccessStore(),user=userModule.useUserStore();const navigations=[];
  access.setAccessToken('old-access');access.setRefreshToken('old-refresh');
  const auth=load('src/store/auth.ts',{
    'vue-router':{useRouter:()=>({currentRoute:vue.ref({fullPath:'/private'}),replace:async to=>navigations.push(to),push:async to=>navigations.push(to)})},
    '@vben/constants':{LOGIN_PATH:'/auth/login'},'@vben/preferences':{},
    '@vben/stores':{...setup,...accessModule,...userModule},'ant-design-vue':{notification:{success(){throw Error('Stale success notification')}}},'#/locales':{$t:v=>v},
    '#/store/audit-policy':{automaticApproval:vue.ref(false),loadAuditPolicy:async()=>{}},
    '#/api':{logoutApi:async()=>{},...api},
  }).useAuthStore();
  return{access,user,auth,navigations};
}
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b});return{promise,resolve,reject};}
async function pendingLoginCannotRestoreSession(){
  const pending=deferred();const f=await sessionFixture({loginApi:()=>pending.promise});
  const login=f.auth.authLogin('username',{});await f.auth.logout(false);
  pending.resolve({accessToken:'late-access',refreshToken:'late-refresh'});await login;
  assert.equal(f.access.accessToken,null);assert.equal(f.access.refreshToken,null);assert.equal(f.navigations.length,1);
}
async function pendingUserInfoCannotRestorePrivateData(){
  const pending=deferred();const f=await sessionFixture({getAuthPermissionInfoApi:()=>pending.promise});
  const info=f.auth.fetchUserInfo();const rejected=assert.rejects(info,/登录状态已变化/);
  await f.auth.logout(false);pending.resolve({user:{nickname:'old-user'},roles:['old-role'],menus:[],permissions:['old-permission']});await rejected;
  assert.equal(f.user.userInfo,null);assert.deepEqual(f.access.accessCodes,[]);
}
async function requestRaces(){
  const f=await sessionFixture({});const pending=deferred();let hooks;
  class Client{responses=[];requests=[];isRefreshing=false;refreshTokenQueue=[];addRequestInterceptor(x){this.requests.push(x)}addResponseInterceptor(x){this.responses.push(x)}}
  const presets=load('src/packages/effects/request/src/request-client/preset-interceptors.ts',{
    '../../../../locales/src':{$t:v=>v},'../../../../utils/src':{isFunction:v=>typeof v==='function'},axios:{isCancel:()=>false},
  });
  const request=load('src/api/request.ts',{
    '@vben/hooks':{isTenantEnable:()=>true,useAppConfig:()=>({apiURL:'/mock'})},
    '@vben/preferences':{preferences:{app:{enableRefreshToken:true,loginExpiredMode:'page'}}},
    '@vben/request':{RequestClient:Client,defaultResponseInterceptor:()=>({}),errorMessageResponseInterceptor:()=>({}),authenticateResponseInterceptor:options=>{hooks=options;return presets.authenticateResponseInterceptor(options)}},
    '@vben/stores':{useAccessStore:()=>f.access},'@vben/utils':{createApiEncrypt:()=>({})},
    'ant-design-vue':{message:{}},'#/store':{useAuthStore:()=>f.auth},'./core':{refreshTokenApi:()=>pending.promise},
  });
  const refresh=hooks.doRefreshToken();const rejected=assert.rejects(refresh,/登录状态已变化/);
  await f.auth.logout(false);pending.resolve({data:{data:{accessToken:'late-refreshed'}}});await rejected;assert.equal(f.access.accessToken,null);
  f.access.setAccessToken('new-account-token');f.access.setRefreshToken('new-account-refresh');
  await hooks.doReAuthenticate({config:{headers:{Authorization:'Bearer old-access'}}});assert.equal(f.access.accessToken,'new-account-token');
  const cfg={headers:{'tenant-id':7,'visit-tenant-id':9}};
  await request.baseRequestClient.requests[0].fulfilled(cfg);assert.equal(cfg.headers['tenant-id'],7);assert.equal(cfg.headers['visit-tenant-id'],9);
  const client={isRefreshing:false,refreshTokenQueue:[],request(){throw Error('Failed refresh must not replay queued requests')}};
  const failed=deferred();let reauthError;const authError={config:{url:'/private',headers:{Authorization:'Bearer old'}},response:{status:401}};
  const interceptor=presets.authenticateResponseInterceptor({client,doRefreshToken:()=>failed.promise,doReAuthenticate:async e=>{reauthError=e},enableRefreshToken:true,formatToken:t=>t});
  const first=interceptor.rejected(authError),firstRejected=assert.rejects(first,/refresh failed/);
  const second=interceptor.rejected({config:{url:'/other',headers:{}},response:{status:401}}),secondRejected=assert.rejects(second,/refresh failed/);
  const originalError=console.error;console.error=()=>{};
  try{failed.reject(new Error('refresh failed'));await Promise.all([firstRejected,secondRejected]);}finally{console.error=originalError;}
  assert.equal(reauthError,authError);assert.equal(client.isRefreshing,false);assert.equal(client.refreshTokenQueue.length,0);
}
(async () => {
  await scenario(false, false, false);
  await scenario(true, false, true);
  await scenario(false, true, false);
  await scenario(true, true, false);
  await scenario('pending', false, false);
  await pendingLoginCannotRestoreSession();
  await pendingUserInfoCannotRestorePrivateData();
  await requestRaces();
  console.log('PASS: pending APIs do not block logout, stale login/user/refresh cannot restore sessions, failed refresh queue rejects; logout redirects and clears session with API success/failure and store reset failures; company and IM stores reset');
})();
scripts/login-theme-live-qa.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
// Actual application/router/forms, simulated APIs, no real authentication.
const assert=require('node:assert/strict');
const {chromium}=require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs=require('fs'),path=require('path');
const output=path.resolve(__dirname,'../../scripts/login-theme-20261015');fs.mkdirSync(output,{recursive:true});
(async()=>{
 const server=await(await import('vite')).createServer({root:path.resolve(__dirname,'..'),server:{host:'127.0.0.1',port:5182}});await server.listen();
 const browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});
 const page=await browser.newPage({viewport:{width:1664,height:941},reducedMotion:'reduce'});
 const errors=[];let authPosts=0;page.on('pageerror',e=>errors.push(e.message));
 await page.route('**/admin-api/**',async route=>{
  const url=route.request().url();if(route.request().method()==='POST')authPosts++;
  const data=url.includes('company-config')?{companyName:'滋补品管理平台',loginTitle:'滋补品行业数字化管理平台',loginDescription:'传承中医药精华 Â· æœåŠ¡å¥åº·ç¾Žå¥½ç”Ÿæ´»'}:url.includes('tenant')?[]:null;
  await route.fulfill({contentType:'application/json',body:JSON.stringify({code:0,data,msg:''})});
 });
 try {
  await page.goto('http://127.0.0.1:5182/auth/login',{waitUntil:'domcontentloaded',timeout:60000});
  await page.getByRole('heading',{name:'欢迎回来'}).waitFor({timeout:60000});
  await page.waitForFunction(()=>!document.querySelector('#__app-loading__')||getComputedStyle(document.querySelector('#__app-loading__')).opacity==='0');
  await page.waitForFunction(()=>document.querySelector('.herbal-auth__background')?.complete);
  const user=page.getByRole('textbox',{name:'账号',exact:true}),password=page.locator('input[autocomplete="current-password"]');
  await user.fill('演示账户');await password.fill('not-a-real-password');await page.locator('.herbal-login__heading').click();await page.waitForTimeout(400);
  assert.equal(await user.getAttribute('autocomplete'),'username');assert.equal(await password.getAttribute('type'),'password');
  await page.getByRole('button',{name:'显示密码',exact:true}).focus();await page.keyboard.press('Space');assert.equal(await password.getAttribute('type'),'text');
  assert.equal(await password.evaluate(e=>getComputedStyle(e).paddingRight),'44px');
  await page.getByRole('button',{name:'隐藏密码',exact:true}).click();assert.equal(await password.getAttribute('type'),'password');
  const remember=page.locator('button[role="checkbox"]').first();await remember.click();assert.equal(await remember.getAttribute('aria-checked'),'true');
  await page.screenshot({path:path.join(output,'desktop-login.png'),fullPage:true});
  const tokens=await page.evaluate(()=>{const s=getComputedStyle(document.documentElement);return{brand:document.documentElement.dataset.brandTheme,primary:s.getPropertyValue('--primary').trim(),background:s.getPropertyValue('--background-deep').trim()}});
  assert.equal(tokens.brand,'herbal');assert.equal(tokens.background,'37 33% 95%');
  await page.locator('.herbal-auth__toolbar button').last().click();await page.waitForFunction(()=>document.documentElement.classList.contains('dark'));await page.waitForTimeout(350);
  assert.equal(await page.evaluate(()=>getComputedStyle(document.documentElement).getPropertyValue('--primary').trim()),'357 64% 73%');
  assert.equal(await page.locator('.herbal-panel').evaluate(e=>getComputedStyle(e).backgroundColor),'rgba(39, 31, 26, 0.97)');
  assert.equal(await page.locator('.herbal-auth__toolbar >div').evaluate(e=>getComputedStyle(e).backgroundColor),'rgb(48, 39, 31)');
  assert.equal(await page.evaluate(()=>getComputedStyle(document.documentElement).filter),'none','Dark styles must not apply a filter to the document');
  await page.screenshot({path:path.join(output,'desktop-dark.png'),fullPage:true});
  await page.locator('.herbal-auth__toolbar button').last().click();await page.waitForFunction(()=>!document.documentElement.classList.contains('dark'));
  await page.setViewportSize({width:390,height:844});await page.waitForTimeout(250);
  assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
  await page.screenshot({path:path.join(output,'mobile-login.png'),fullPage:true});
  await user.fill('');await password.fill('');await page.getByRole('button',{name:'login',exact:true}).click();
  await page.getByText('请输入用户名',{exact:true}).first().waitFor();assert.equal(authPosts,0);
  assert.deepEqual(errors,[]);
  const result={passed:true,scope:'Real application/router/theme/login components; mocked backend and company APIs, demo account, no real authentication',checks:['dynamic company config','background loaded','autocomplete','keyboard password toggle','remember account','light theme variables','dark theme switching','mobile no overflow','required fields block backend'],tokens,screenshots:['desktop-login.png','desktop-dark.png','mobile-login.png']};
  fs.writeFileSync(path.join(output,'result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
 } catch(error){console.error(JSON.stringify({message:error.message,errors}));throw error;}
 finally{await browser.close();await server.close();}
})();
scripts/one-click-schedule-qa.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
// Component interaction check with simulated API data; never creates production tasks.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(path.join(root, 'package.json'));
const { parse, compileScript } = require('vue/compiler-sfc');
const ts = require('typescript');
const { chromium } = require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const output = path.resolve(root, '../scripts/one-click-schedule-20261014');
fs.mkdirSync(output, { recursive: true });
const modal = `import {defineComponent,h,reactive,onMounted} from 'vue';
export function useVbenModal(options){const state=reactive({open:true,locked:false,confirmDisabled:true,...options});
const api={getData:()=>({workOrderIds:[1,2]}),setState:v=>Object.assign(state,v),lock:()=>state.locked=true,unlock:()=>state.locked=false,close:async()=>{if(options.onBeforeClose&&!options.onBeforeClose())return;state.open=false;await options.onOpenChange(false)}};
window.qaModal={state,api,options};onMounted(()=>options.onOpenChange(true));
const Modal=defineComponent({props:['title'],setup(props,{slots}){return()=>state.open?h('section',{class:'qa-modal'},[
h('header',props.title),h('div',{class:'qa-content'},slots.default?.()),h('footer',[
h('button',{class:'qa-close',disabled:state.locked,onClick:()=>api.close()},'取消'),
h('button',{class:'qa-confirm',disabled:state.locked||state.confirmDisabled,onClick:()=>options.onConfirm()},state.locked?'正在排产…':state.confirmText)])]):null}});return[Modal,api]}`;
const api = `export async function previewBatchSchedule(request){window.qa.previewCalls++;await new Promise(r=>setTimeout(r,120));
const blocked=window.qa.mode==='blocked',late=window.qa.mode==='late';return {canExecute:!blocked,previewHash:'test-hash',previewTime:'2026-10-14 15:00:00',warnings:late?['工单WO-001预计完成时间晚于交期']:[],
orders:[{workOrderId:1,workOrderCode:'WO-001',quantity:100,taskCount:blocked?0:2,requestDate:'2026-10-16 00:00:00',endTime:'2026-10-16 12:00:00',late,error:blocked?'WO-001:工序成型没有可用工作站及有效设备产能,请先维护':undefined},{workOrderId:2,workOrderCode:'WO-002',quantity:200,taskCount:blocked?0:2,requestDate:'2026-10-19 00:00:00',endTime:'2026-10-19 13:00:00',late:false,error:blocked?'WO-002:工序包装缺少设备产能,请先维护':undefined}],
tasks:blocked?[]:[{workOrderId:1,workOrderCode:'WO-001',processId:1,processName:'成型',workstationName:'成型工作站A',machineryNames:'成型设备A01',quantity:100,hourlyCapacity:50,duration:2,startTime:'2026-10-16 09:00:00',endTime:'2026-10-16 11:00:00'},{workOrderId:1,workOrderCode:'WO-001',processId:2,processName:'包装',workstationName:'包装工作站B',machineryNames:'包装设备B01',quantity:100,hourlyCapacity:100,duration:1,startTime:'2026-10-16 11:00:00',endTime:'2026-10-16 12:00:00'},{workOrderId:2,workOrderCode:'WO-002',processId:1,processName:'成型',workstationName:'成型工作站A',machineryNames:'成型设备A01',quantity:200,hourlyCapacity:100,duration:2,startTime:'2026-10-19 09:00:00',endTime:'2026-10-19 11:00:00'},{workOrderId:2,workOrderCode:'WO-002',processId:2,processName:'包装',workstationName:'包装工作站B',machineryNames:'包装设备B01',quantity:200,hourlyCapacity:100,duration:2,startTime:'2026-10-19 11:00:00',endTime:'2026-10-19 13:00:00'}]};}
export async function executeBatchSchedule(request){window.qa.executeCalls++;window.qa.lastRequest=request;await new Promise(r=>setTimeout(r,200));if(window.qa.mode==='executeError')throw new Error('排产数据已变化,请重新生成预览后确认');return{taskIds:[1,2,3,4]}}`;
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>一键排产交互验证</title>
<style>body{margin:0;background:#f4f6f8;font-family:Microsoft YaHei,sans-serif;color:#172033}.qa-modal{width:calc(100% - 32px);max-width:1200px;margin:20px auto;background:white;border-radius:8px}.qa-modal header{padding:18px 24px;font-size:18px;font-weight:600;border-bottom:1px solid #e8ebee}.qa-content{padding:24px}.qa-modal footer{display:flex;justify-content:flex-end;gap:12px;padding:16px 24px;border-top:1px solid #e8ebee}.qa-modal button{padding:8px 16px;border:1px solid #d9d9d9;border-radius:6px;background:white;cursor:pointer}.qa-modal .qa-confirm{background:#1677ff;color:white;border-color:#1677ff}.qa-modal button:disabled{color:#8c8c8c;background:#f5f5f5;border-color:#d9d9d9;cursor:default}.space-y-5>*+*{margin-top:20px}.flex{display:flex}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.gap-3{gap:12px}.gap-2{gap:8px}.font-medium{font-weight:500}.text-sm{font-size:14px}.text-muted-foreground{color:#657083}.text-destructive{color:#cf1322}.mb-2{margin-bottom:8px}.mt-2{margin-top:8px}h3{font-size:15px;margin-top:0}p{margin:4px 0}@media(max-width:600px){.qa-content{padding:16px}.qa-modal header{padding:16px}.ant-descriptions{overflow-x:auto}}</style>
<div id="app"></div><script type="module" src="/qa-entry.js"></script></html>`;
const entry = `import{createApp,h}from'vue';import AutoSchedule from'/src/views/mes/pro/task/modules/auto-schedule.vue';window.qa={mode:new URLSearchParams(location.search).get('mode')||'valid',previewCalls:0,executeCalls:0,success:0};createApp({render:()=>h(AutoSchedule,{onSuccess:()=>window.qa.success++})}).mount('#app')`;
const server = await createServer({ root, configFile: false, appType: 'custom', cacheDir: 'node_modules/.cache/one-click-schedule-qa', server: { host: '127.0.0.1', port: 0 },
  plugins: [{ name: 'schedule-qa', enforce: 'pre',
    resolveId(id) { if (id === '@vben/common-ui') return '\0qa-modal'; if (id === '#/api/mes/pro/task') return '\0qa-api'; if (id === '/qa-entry.js') return '\0qa-entry'; },
    load(id) { if (id === '\0qa-modal') return modal; if (id === '\0qa-api') return api; if (id === '\0qa-entry') return entry; },
    transform(code, id) { if (!id.endsWith('.vue')) return;
      const { descriptor } = parse(code); const script = compileScript(descriptor, { id: 'schedule-qa', inlineTemplate: true });
      return { code: ts.transpileModule(script.content, { compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } }).outputText, map: null };
    },
    configureServer(server) { server.middlewares.use((request,response,next)=>{if(request.url?.startsWith('/?')||request.url==='/'){response.setHeader('Content-Type','text/html;charset=utf-8');response.end(html)}else next()}); },
  }], optimizeDeps: { entries: [], noDiscovery: true, include: ['vue', 'ant-design-vue', 'dayjs'] } });
await server.listen();
let browser;
const errors=[];
try {
  browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});
  const page=await browser.newPage({viewport:{width:1440,height:1000}});
  page.on('pageerror',error=>errors.push(error.message));
  const url=`http://127.0.0.1:${server.httpServer.address().port}`;
  await page.goto(url);await page.getByText('工单校验',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),true);
  assert.equal(await page.evaluate(()=>window.qa.previewCalls),1,'Opening modal must generate exactly one preview');
  await page.screenshot({path:path.join(output,'desktop-preview.png'),fullPage:true});
  await page.locator('.ant-picker').click();
  await page.locator('td[title="2026-10-20"]').click();
  await page.locator('.ant-picker-ok button').click();
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Changed earliest time must invalidate preview');
  await page.getByText('生成排产预览',{exact:true}).click();await page.getByText('工单校验',{exact:true}).waitFor();
  await page.locator('.qa-confirm').click();
  assert.equal(await page.locator('.qa-close').isEnabled(),false,'Cancel must be disabled while executing');
  await page.waitForFunction(()=>window.qa.success===1);
  assert.equal(await page.evaluate(()=>window.qa.executeCalls),1,'Confirm must execute only once');
  await page.goto(url+'/?mode=blocked');await page.getByText('工单校验',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Configuration errors must block submission');
  await page.setViewportSize({width:390,height:844});await page.screenshot({path:path.join(output,'mobile-blocked.png'),fullPage:true});
  await page.goto(url+'/?mode=late');await page.getByText('工单校验',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Late risk requires acknowledgement');
  await page.getByText('我已核对并接受以上交期延期风险',{exact:true}).click();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),true);
  await page.locator('.qa-confirm').click();await page.waitForFunction(()=>window.qa.success===1);
  assert.equal(await page.evaluate(()=>window.qa.lastRequest.allowLate),true);
  await page.goto(url+'/?mode=executeError');await page.getByText('工单校验',{exact:true}).waitFor();
  await page.locator('.qa-confirm').click();await page.getByText('排产数据已变化,请重新生成预览后确认',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Failed execution must discard old preview');
  assert.equal(await page.locator('.qa-close').isEnabled(),true,'Failure must unlock modal');
  assert.deepEqual(errors,[]);
  const result={passed:true,scope:'Actual scheduling component, simulated APIs and modal adapter; no production writes',checks:['initial preview','time change invalidates preview','single submit','execution locks cancel','missing config blocks','late acknowledgement','server rejection unlocks and invalidates'],screenshots:['desktop-preview.png','mobile-blocked.png']};
  fs.writeFileSync(path.join(output,'frontend-result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
} catch (error) {
  console.error(JSON.stringify({message:error.message,browserErrors:errors}));
  throw error;
} finally { await browser?.close();await server.close(); }
scripts/spare-management-qa.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
// Actual Vue components with simulated API data; no production stock writes.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
import {createServer} from 'vite';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
const require=createRequire(path.join(root,'package.json'));
const {parse,compileScript}=require('vue/compiler-sfc'),ts=require('typescript');
const {chromium}=require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const output=path.resolve(root,'../scripts/spare-management-20261014');fs.mkdirSync(output,{recursive:true});
const modal=`import {defineComponent,h,reactive,onMounted} from 'vue';
export const Page=defineComponent({setup(p,{slots}){return()=>h('main',slots.default?.())}});
export function useVbenModal(options){
 if(options.connectedComponent||!options.onOpenChange)return[defineComponent({setup(){return()=>null}}),{setData(){return this},open(){}}];
 const state=reactive({open:true,locked:false,...options});const api={getData:()=>window.qa.data,setState:v=>Object.assign(state,v),lock:()=>state.locked=true,unlock:()=>state.locked=false,close:async()=>{if(options.onBeforeClose&&!options.onBeforeClose())return;state.open=false;await options.onOpenChange(false)}};
 window.qaModal={state,api,options};onMounted(()=>options.onOpenChange(true));
 return[defineComponent({props:['title'],setup(props,{slots}){return()=>state.open?h('section',{class:'qa-modal'},[h('header',props.title),h('div',slots.default?.()),h('footer',[h('button',{class:'qa-close',disabled:state.locked,onClick:()=>api.close()},'取消'),h('button',{class:'qa-confirm',disabled:state.locked,onClick:()=>options.onConfirm().catch(()=>{})},'确认')])]):null}}),api];}`;
const api=`export const movementLabels={1:'初始库存',2:'入库',3:'维修领用',4:'退回'};
export async function getSparePage(){return {list:window.qa.parts,total:window.qa.parts.length}}
export async function getSpare(){return window.qa.parts[0]}
export async function getSpareMovements(){return {list:window.qa.movements,total:window.qa.movements.length}}
export async function spareAction(type,data){window.qa.calls++;window.qa.request={type,...data};await new Promise(r=>setTimeout(r,250));if(window.qa.fail)throw Error('server rejection');return 1}
export async function createSpare(data){window.qa.saved=data;return 3}
export async function updateSpare(data){window.qa.saved=data}
export async function deleteSpare(){}
`;
const entry=`import {createApp,h} from 'vue';import 'ant-design-vue/dist/reset.css';
import Ledger from '/src/views/mes/dv/spare/index.vue';import Records from '/src/views/mes/dv/spare/modules/records.vue';import Action from '/src/views/mes/dv/spare/modules/action.vue';import Form from '/src/views/mes/dv/spare/modules/form.vue';
const mode=new URLSearchParams(location.search).get('mode')||'ledger';
const parts=[{id:1,code:'SP-001',name:'深沟球轴承',specification:'6205-2RS',unit:'ä»¶',location:'备件库 A-01',stockQuantity:10,safetyQuantity:15,status:0},{id:2,code:'SP-002',name:'传动皮带',specification:'B-1250',unit:'条',location:'备件库 B-02',stockQuantity:25,safetyQuantity:5,status:0}];
const movement={id:1,sparePartId:1,sparePartCode:'SP-001',sparePartName:'深沟球轴承',specification:'6205-2RS',unit:'ä»¶',type:3,quantity:4,returnedQuantity:1,returnableQuantity:3,repairCode:'WX-20261014-001',repairId:1,machineryName:'数控加工中心',receiverName:'维修员甲',remark:'更换主轴轴承',createTime:'2026-10-14 15:30:00'};
window.qa={parts,movements:[movement],calls:0,data:mode==='return'?{type:'return',original:movement}:mode==='form'?{}:{type:mode==='receive'?'receive':'issue',part:parts[0],repairId:1,repairCode:'WX-20261014-001'}};
const C=mode==='ledger'?Ledger:mode==='records'?Records:mode==='form'?Form:Action;
const app=createApp({render:()=>mode==='records'?h('main',[h(C)]):h(C)});app.directive('access',{});app.mount('#app');`;
const html=`<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>备件组件验证 Â· æ¨¡æ‹Ÿæ•°æ®</title><style>body{margin:0;background:#f5f5f5;color:#1f1f1f;font:14px system-ui}main{padding:24px;background:white;margin:20px}.qa-modal{background:white;max-width:600px;margin:24px auto;padding:24px}header{font-size:20px;font-weight:600;margin-bottom:20px}footer{display:flex;gap:12px;justify-content:flex-end;padding-top:16px}footer button{padding:8px 20px}footer button:disabled{opacity:.5}@media(max-width:640px){main{margin:8px;padding:12px}.qa-modal{margin:8px;padding:16px}} .flex{display:flex}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-3{gap:12px}.gap-y-3{row-gap:12px}.mb-4{margin-bottom:16px}.mb-3{margin-bottom:12px}.mt-4{margin-top:16px}.w-full{width:100%}.w-28{width:112px}.w-36{width:144px}.text-lg{font-size:18px}.text-base{font-size:16px}.font-semibold{font-weight:600}.grid{display:grid}.gap-x-4{column-gap:16px}.ml-2{margin-left:8px}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:col-span-2{grid-column:span 2}}</style><div id="app"></div><script type="module" src="/qa-entry.js"></script></html>`;
const server=await createServer({root,configFile:false,appType:'custom',cacheDir:'node_modules/.cache/spare-management-qa',server:{host:'127.0.0.1',port:0},plugins:[{name:'spare-qa',enforce:'pre',
resolveId(id){if(id==='@vben/common-ui')return '\0qa-modal';if(id==='#/api/mes/dv/spare')return '\0qa-api';if(id==='#/api/mes/dv/repair')return '\0qa-repair';if(id==='#/api/system/user')return '\0qa-user';if(id==='/qa-entry.js')return '\0qa-entry'},
load(id){if(id==='\0qa-modal')return modal;if(id==='\0qa-api')return api;if(id==='\0qa-repair')return 'export async function getRepairPage(){return {list:[{id:1,code:"WX-001",status:0,machineryName:"设备A"}]}}';if(id==='\0qa-user')return 'export async function getSimpleUserList(){return [{id:1,nickname:"维修员甲"}]}';if(id==='\0qa-entry')return entry},
transform(code,id){if(!id.endsWith('.vue'))return;const {descriptor}=parse(code);const script=compileScript(descriptor,{id,inlineTemplate:true});return{code:ts.transpileModule(script.content,{compilerOptions:{target:ts.ScriptTarget.ESNext,module:ts.ModuleKind.ESNext}}).outputText,map:null}},
configureServer(s){s.middlewares.use((req,res,next)=>{if(req.url==='/'||req.url?.startsWith('/?')){res.setHeader('Content-Type','text/html;charset=utf-8');res.end(html)}else next()})}
}],optimizeDeps:{entries:[],noDiscovery:true,include:['vue','ant-design-vue']}});
await server.listen();let browser;const errors=[];
try{
 browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});const page=await browser.newPage({viewport:{width:1440,height:950}});
 page.on('pageerror',e=>errors.push(e.message));const url='http://127.0.0.1:'+server.httpServer.address().port;
 await page.goto(url);await page.getByText('深沟球轴承',{exact:true}).waitFor();await page.screenshot({path:path.join(output,'desktop-ledger.png'),fullPage:true});
 await page.goto(url+'/?mode=records');await page.getByText('更换主轴轴承',{exact:true}).waitFor();await page.setViewportSize({width:390,height:844});await page.screenshot({path:path.join(output,'mobile-records.png'),fullPage:true});
 await page.goto(url+'/?mode=issue');await page.locator('.qa-confirm').waitFor();await page.locator('.qa-confirm').click();await page.waitForTimeout(100);assert.equal(await page.evaluate(()=>window.qa.calls),0,'Missing receiver and reason blocks issue');
 await page.locator('#form_item_receiverUserId').click();await page.locator('.ant-select-item-option-content').filter({hasText:'维修员甲'}).click();
 await page.locator('textarea').fill('更换主轴轴承');await page.locator('.ant-input-number-input').fill('11');await page.locator('.qa-confirm').click();await page.getByText('超过当前可用库存',{exact:true}).waitFor();assert.equal(await page.evaluate(()=>window.qa.calls),0);
 await page.locator('.ant-input-number-input').fill('2');await page.setViewportSize({width:1440,height:950});await page.screenshot({path:path.join(output,'desktop-issue.png'),fullPage:true});
 await page.locator('.qa-confirm').click();await page.waitForFunction(()=>window.qa.calls===1);assert.equal(await page.locator('.qa-close').isEnabled(),false);await page.waitForFunction(()=>!window.qaModal.state.open);assert.equal(await page.evaluate(()=>window.qa.calls),1);
 await page.goto(url+'/?mode=return');await page.locator('.qa-confirm').waitFor();await page.locator('textarea').fill('未使用退回');await page.locator('.ant-input-number-input').fill('4');await page.locator('.qa-confirm').click();await page.getByText('超过可退回数量',{exact:true}).waitFor();assert.equal(await page.evaluate(()=>window.qa.calls),0);
 await page.locator('.ant-input-number-input').fill('2');await page.evaluate(()=>window.qa.fail=true);await page.locator('.qa-confirm').click();await page.waitForFunction(()=>window.qa.calls===1&&!window.qaModal.state.locked);assert.equal(await page.locator('.qa-close').isEnabled(),true);
 const oldKey=await page.evaluate(()=>window.qa.request.requestKey);await page.evaluate(()=>window.qa.fail=false);await page.locator('.qa-confirm').click();await page.waitForFunction(()=>!window.qaModal.state.open);assert.equal(await page.evaluate(()=>window.qa.request.requestKey),oldKey,'Failed submission retry uses same idempotency key');
 await page.goto(url+'/?mode=form');await page.locator('.qa-confirm').waitFor();await page.locator('.qa-confirm').click();await page.waitForTimeout(80);assert.equal(await page.evaluate(()=>window.qa.saved),undefined,'Required fields block empty part creation');
 await page.goto(url+'/?mode=issue');await page.locator('.qa-confirm').waitFor();await page.locator('#form_item_receiverUserId').click();await page.locator('.ant-select-item-option-content').filter({hasText:'维修员甲'}).click();await page.locator('textarea').fill('更换主轴轴承');await page.locator('.ant-input-number-input').fill('2');await page.locator('header').click();await page.waitForTimeout(300);await page.screenshot({path:path.join(output,'desktop-issue.png'),fullPage:true});
 assert.deepEqual(errors,[]);const result={passed:true,scope:'Actual Vue/Ant Design components with mock APIs and modal adapter; no production writes',checks:['ledger display and shortage','records mobile display','required issue fields','issue stock limit','single submit and cancel lock','return limit','failure unlock','retry idempotency key','required part fields'],screenshots:['desktop-ledger.png','mobile-records.png','desktop-issue.png']};fs.writeFileSync(path.join(output,'frontend-result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
}finally{await browser?.close();await server.close()}
src/adapter/form.ts
@@ -8,6 +8,10 @@
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { isMobile } from '@vben/utils';
import { watch } from 'vue';
import { automaticApproval } from '#/store/audit-policy';
import { isAuditField } from '#/utils/audit-ui-policy';
async function initSetupVbenForm() {
  setupVbenForm<ComponentType>({
@@ -61,7 +65,16 @@
  });
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
const useVbenForm = (options: FormProps<ComponentType, ComponentPropsMap>) => {
  const result = useForm<ComponentType, ComponentPropsMap>(options);
  const schema = options.schema;
  if (schema) {
    watch(automaticApproval, () => {
      result[1].setState({ schema: schema.filter((item) => !automaticApproval.value || !isAuditField(item)) });
    }, { immediate: true });
  }
  return result;
};
export { initSetupVbenForm, useVbenForm, z };
src/adapter/vxe-table.ts
@@ -3,7 +3,7 @@
import type { ComponentPropsMap, ComponentType } from './component';
import { h } from 'vue';
import { h, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
@@ -35,6 +35,8 @@
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { isAuditField } from '#/utils/audit-ui-policy';
import { useVbenForm } from './form';
@@ -403,6 +405,17 @@
export * from '#/components/table-action';
export const useVbenVxeGrid = <T extends Record<string, any>>(
  ...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
) => {
  const options = rest[0];
  const columns = options.gridOptions?.columns;
  const schema = options.formOptions?.schema;
  const applyPolicy = () => ({
    ...(columns ? { gridOptions: { columns: columns.filter((item) => !automaticApproval.value || !isAuditField(item)) } } : {}),
    ...(schema ? { formOptions: { schema: schema.filter((item) => !automaticApproval.value || !isAuditField(item)) } } : {}),
  });
  const result = useGrid<T, ComponentType, ComponentPropsMap>(options);
  watch(automaticApproval, () => result[1].setState(applyPolicy()), { immediate: true });
  return result;
};
export type * from '@vben/plugins/vxe-table';
src/api/agent/conversation.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,641 @@
import type { InternalAxiosRequestConfig } from '@vben/request';
import {
  defaultResponseInterceptor,
  RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import {
  bindImportAttachment,
  createImportSession,
} from '#/api/mdm/item/agent';
import { uploadFile } from '#/api/system/storage';
/**
 * æ¨¡å—数据补录会话服务(Python)不在 /admin-api ä¸‹ï¼Œ
 * å¼€å‘期由 Vite ä»£ç† /agent-api è½¬å‘到 127.0.0.1:8091,且不返回 CommonResult åŒ…装。
 */
const agentClient = new RequestClient({
  baseURL: '/agent-api',
  responseReturn: 'body',
  timeout: 120_000,
});
agentClient.addRequestInterceptor({
  fulfilled: (config: InternalAxiosRequestConfig) => {
    const token = useAccessStore().accessToken;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
});
// Python æœåŠ¡ç›´æŽ¥è¿”å›žä¸šåŠ¡ JSON,不套 CommonResult;responseReturn: 'body' æ—¶åŽŸæ ·è¿”å›žå“åº”ä½“ã€‚
agentClient.addResponseInterceptor(
  defaultResponseInterceptor({
    codeField: 'code',
    dataField: 'data',
    successCode: 0,
  }),
);
export namespace AgentConversationApi {
  export interface FieldSpec {
    name: string;
    label: string;
    required: boolean;
    kind: string;
  }
  export interface ModuleSpec {
    module_id: string;
    title: string;
    capability: string;
    bridge: string;
    permission: string;
    description: string;
    required_fields: string[];
    fields: FieldSpec[];
  }
  export interface Capabilities {
    version: string;
    formats: string[];
    ocr: boolean;
    cross_file_dedupe: boolean;
    import_modes: string[];
    notes: string[];
    modules: ModuleSpec[];
    durable_tasks?: {
      enabled: boolean;
      events: string;
      fact_source: string;
      transport: string;
    };
  }
  export interface AuthorizedCapability {
    tool: string;
    module: string;
    operation: string;
    allowed_fields: string[];
    scope_summary: string;
    can_execute: boolean;
  }
  export interface AuthorizedCapabilityCatalog {
    policy_revision: string;
    authorization_scope: string;
    scope_summary: string;
    capabilities: AuthorizedCapability[];
  }
  export interface BusinessModelInfo {
    id: number;
    name: string;
    model: string;
    platform: string;
    temperature?: number | null;
    max_tokens?: number | null;
    max_contexts?: number | null;
  }
  export interface Issue {
    code?: string;
    field?: string;
    message: string;
    candidate_id?: string;
  }
  export interface Summary {
    total?: number;
    create?: number;
    skip?: number;
    blocked?: number;
    success?: number;
    failed?: number;
    skipped?: number;
    unknown?: number;
  }
  export interface ReceiptRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: string;
    status: string;
    target_item_id?: number;
    error_code?: string;
    error_message?: string;
  }
  export interface Receipt {
    command_id: string;
    idempotency_key: string;
    status: string;
    attachment_id: number;
    source_sha256: string;
    preflight_hash: string;
    summary: Summary;
    rows: ReceiptRow[];
    create_time?: string;
    completed_time?: string;
    error_code?: string;
    error_message?: string;
  }
  export interface ModuleCandidate {
    module_id: string;
    title: string;
    capability: string;
    description: string;
  }
  export interface MissingField {
    name: string;
    label: string;
  }
  /** å¡ç‰‡å­—段按 kind å¤ç”¨ï¼Œæœªä½¿ç”¨çš„字段为空。 */
  export interface Card {
    kind: string;
    title?: string;
    body?: string;
    module_id?: string;
    module_title?: string;
    capability?: string;
    revision?: number;
    issues?: Issue[];
    warnings?: Issue[];
    repair_guidance?: Array<{ field?: string; guidance: string }>;
    summary?: Summary;
    can_commit?: boolean;
    side_effects?: string[];
    sync_mes?: boolean | null;
    total?: number;
    actions?: string[];
    notice?: string;
    candidates?: ModuleCandidate[];
    missing_fields?: MissingField[];
    columns?: string[];
    command_id?: string;
    status?: string;
    rows?: ReceiptRow[];
  }
  export interface Message {
    message_id: string;
    role: string;
    type: string;
    text: string;
    cards?: Card[];
  }
  export interface Interrupt {
    interrupt_id: string;
    kind: string;
    revision: number;
    validation_revision?: number | null;
    card: Card;
  }
  export interface RowView {
    candidate_id: string;
    values: Record<string, string>;
    issues: unknown[];
    warnings: unknown[];
  }
  export interface ConversationAttachment {
    attachment_id: string;
    /** ä»…最新附件解析出文件名;历史附件可能为空。 */
    filename: string;
  }
  export interface Conversation {
    conversation_id: string;
    title: string;
    status: string;
    module_id: string;
    module_title: string;
    capability: string;
    data_scope_summary: string;
    model_id?: number | null;
    policy_revision: string;
    revision: number;
    validation_revision?: number | null;
    sync_mes?: boolean;
    total: number;
    summary: Summary;
    issues: Issue[];
    warnings: Issue[];
    missing_fields: string[];
    columns: string[];
    /** å€™é€‰è¡Œå®žé™…有值的字段,用于渲染明细表。 */
    row_fields: Array<{ name: string; label: string }>;
    attachments: ConversationAttachment[];
    messages: Message[];
    interrupts: Interrupt[];
    /** æœªæäº¤æ—¶åŽç«¯è¿”回空对象 {},提交后才有完整回执。 */
    receipt?: null | Partial<Receipt>;
    last_error: string;
    rows: RowView[];
    created_at: string;
    updated_at: string;
    finished: boolean;
    commit_enabled: boolean;
    mes_sync_default: boolean;
  }
  export interface ConversationSummary {
    conversation_id: string;
    title: string;
    status: string;
    module_id: string;
    module_title: string;
    revision: number;
    created_at: string;
    updated_at: string;
  }
  export interface PageResult {
    items: ConversationSummary[];
    total: number;
    page: number;
    page_size: number;
  }
  export interface ConversationEvent {
    conversation_id: string;
    payload: Record<string, unknown>;
    seq: number;
    type:
      | 'card'
      | 'error'
      | 'message'
      | 'progress'
      | 'receipt'
      | 'resync'
      | 'status';
  }
  export interface CreateReq {
    title?: string;
    text?: string;
    attachment_id?: string;
    module_id?: string;
    model_id?: number;
    constraints?: ConversationConstraints;
  }
  export interface ConversationConstraints {
    scenario_paths: string[];
    time_start?: string;
    time_end?: string;
    record_limit?: number;
    industry?: string;
    skip_rest_days?: boolean;
    rest_day_description?: string;
    region?: string;
    instruction?: string;
    priority_fields: string[];
  }
  export interface ActionReq {
    interrupt_id: string;
    revision: number;
    action: string;
    payload: Record<string, unknown>;
    model_id?: number;
  }
  export interface MessageReq {
    text?: string;
    attachment_id?: string;
    model_id?: number;
  }
}
export namespace AgentTaskApi {
  export interface Progress {
    mode: 'determinate' | 'indeterminate';
    completed: number;
    total: null | number;
    percent: null | number;
    unit?: string;
  }
  export interface Result {
    attachment_id?: number;
    attachment_ids?: number[];
    candidate_count?: number;
    continuation_supported?: boolean;
    evidence_ref?: {
      backend: string;
      candidate_count?: number;
      revision: string;
      task_id: string;
    } | null;
    filename?: string;
    filenames?: string[];
    module_id?: string;
    module_title?: string;
    result_kind?: string;
    source_sha256?: string;
    source_sha256s?: string[];
    review_summary?: {
      conflict_groups: number;
      exact_duplicates_merged: number;
      incomplete_keys: number;
      requires_review: boolean;
    };
    warnings?: string[];
  }
  export interface Task {
    task_id: string;
    owner_id: string;
    kind: string;
    status: string;
    stage: string;
    attempt: number;
    sequence: number;
    progress: Progress;
    message: string;
    result: null | Result;
    error: null | { code?: string; message?: string };
    allowed_actions: string[];
    created_at: null | string;
    updated_at: null | string;
    started_at: null | string;
    finished_at: null | string;
  }
  export interface PageResult {
    items: Task[];
    total: number;
    page: number;
    page_size: number;
  }
  export interface CreateReq {
    attachment_id?: number;
    attachment_ids?: number[];
    module_id?: string;
    constraints?: AgentConversationApi.ConversationConstraints;
  }
}
/** èƒ½åŠ›å£°æ˜Žï¼šæ”¯æŒçš„æ ¼å¼ã€æ¨¡å—ä¸Žé™åˆ¶ */
export function getAgentCapabilities() {
  return agentClient.get<AgentConversationApi.Capabilities>('/v1/capabilities');
}
/** è¯»å–当前登录用户经 Java åŽç«¯è£å‰ªåŽçš„可查询、可操作能力。 */
export function getAuthorizedAgentCapabilities() {
  return agentClient.get<AgentConversationApi.AuthorizedCapabilityCatalog>(
    '/v1/authorization/capabilities',
  );
}
/** å½“前数据补录链路实际使用的业务模型;不返回 API å¯†é’¥æˆ–服务地址。 */
export function getActiveBusinessModel() {
  return agentClient.get<AgentConversationApi.BusinessModelInfo>('/v1/model');
}
/** å¯åˆ‡æ¢çš„业务模型列表,按后端排序返回,默认使用第一个。 */
export function getAgentModels() {
  return agentClient.get<AgentConversationApi.BusinessModelInfo[]>('/v1/models');
}
/** åˆ†é¡µè¯»å–当前用户的持久后台任务。 */
export function listAgentTasks(params?: {
  page?: number;
  page_size?: number;
  status?: string;
}) {
  return agentClient.get<AgentTaskApi.PageResult>('/v1/tasks', { params });
}
/** åˆ›å»ºæ–‡æ¡£åŽå°åˆ†æžä»»åŠ¡ï¼ŒæœåŠ¡ç«¯ç«‹å³è¿”å›ž HTTP 202 å¿«ç…§ã€‚ */
export function createDocumentTask(data: AgentTaskApi.CreateReq) {
  return agentClient.post<AgentTaskApi.Task>('/v1/tasks/document-onboarding', data);
}
/** è¯»å–任务事实快照。 */
export function getAgentTask(taskId: string) {
  return agentClient.get<AgentTaskApi.Task>(`/v1/tasks/${taskId}`);
}
/** å–消任务并撤销短期委托票据。 */
export function cancelAgentTask(taskId: string) {
  return agentClient.delete<AgentTaskApi.Task>(`/v1/tasks/${taskId}`);
}
/** ä½¿ç”¨è®¤è¯è¯·æ±‚读取任务 SSE;游标用于断线续传。 */
export async function streamAgentTaskEvents(
  taskId: string,
  since: number,
  signal: AbortSignal,
  onEvent: (event: AgentTaskApi.Task) => void,
) {
  const token = useAccessStore().accessToken;
  const response = await fetch(
    `/agent-api/v1/tasks/${encodeURIComponent(taskId)}/events?since=${since}`,
    {
      credentials: 'same-origin',
      headers: {
        Accept: 'text/event-stream',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      signal,
    },
  );
  if (!response.ok) throw new Error(`任务事件连接失败(HTTP ${response.status})`);
  if (!response.body) throw new Error('当前浏览器不支持流式事件');
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let cursor = since;
  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value, { stream: !done }).replaceAll('\r\n', '\n');
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const data = frame
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join('\n');
      if (!data) continue;
      const event = JSON.parse(data) as AgentTaskApi.Task;
      if (!Number.isSafeInteger(event.sequence) || event.sequence <= cursor) continue;
      cursor = event.sequence;
      onEvent(event);
    }
    if (done) break;
  }
  return cursor;
}
/** ä¼šè¯åˆ—表 */
export function listAgentConversations(params?: {
  page?: number;
  page_size?: number;
  status?: string;
}) {
  return agentClient.get<AgentConversationApi.PageResult>(
    '/v1/conversations',
    { params },
  );
}
/** æ–°å»ºè¡¥å½•会话 */
export function createAgentConversation(data: AgentConversationApi.CreateReq) {
  return agentClient.post<AgentConversationApi.Conversation>(
    '/v1/conversations',
    data,
  );
}
/** è¯»å–会话 */
export function getAgentConversation(conversationId: string) {
  return agentClient.get<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}`,
  );
}
/** åˆ é™¤ä¼šè¯ï¼šåŒæ—¶æ¸…理检查点、事件与 Java ä¾§è¡¥å½•附件 */
export function deleteAgentConversation(conversationId: string) {
  return agentClient.delete<{
    attachments_deleted: number;
    conversation_id: string;
    deleted: boolean;
  }>(`/v1/conversations/${conversationId}`);
}
/**
 * è¯»å–会话附件。mode ä¸º inline æ—¶ PDF/图片由浏览器直接预览,
 * å…¶ä½™ç±»åž‹æœåŠ¡ç«¯ä¼šè‡ªåŠ¨é™çº§ä¸ºä¸‹è½½ã€‚
 */
export function downloadAgentConversationAttachment(
  conversationId: string,
  attachmentId: string,
  mode: 'download' | 'inline',
) {
  return agentClient.download<{
    data: Blob;
    headers: Record<string, string>;
  }>(`/v1/conversations/${conversationId}/attachment`, {
    params: { attachment_id: attachmentId, mode },
    responseReturn: 'raw',
  });
}
/**
 * ä½¿ç”¨ fetch è¯»å–带认证头的 SSE。EventSource ä¸èƒ½è®¾ç½® Authorization,不能用于本接口。
 * æœåŠ¡ç«¯ä¼šä¸»åŠ¨ç»“æŸçŸ­è¿žæŽ¥ï¼›è°ƒç”¨æ–¹è´Ÿè´£æºå¸¦è¿”å›žæ¸¸æ ‡é‡è¿žã€‚
 */
export async function streamAgentConversationEvents(
  conversationId: string,
  since: number,
  signal: AbortSignal,
  onEvent: (event: AgentConversationApi.ConversationEvent) => void,
  onOpen?: () => void,
) {
  const token = useAccessStore().accessToken;
  const response = await fetch(
    `/agent-api/v1/conversations/${encodeURIComponent(conversationId)}/events?since=${since}`,
    {
      credentials: 'same-origin',
      headers: {
        Accept: 'text/event-stream',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      signal,
    },
  );
  if (!response.ok) {
    throw new Error(`事件连接失败(HTTP ${response.status})`);
  }
  if (!response.body) {
    throw new Error('当前浏览器不支持流式事件');
  }
  onOpen?.();
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let cursor = since;
  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value, { stream: !done }).replaceAll('\r\n', '\n');
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const data = frame
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join('\n');
      if (!data) continue;
      const event = JSON.parse(data) as AgentConversationApi.ConversationEvent;
      if (!Number.isSafeInteger(event.seq) || event.seq <= cursor) continue;
      cursor = event.seq;
      onEvent(event);
    }
    if (done) break;
  }
  return cursor;
}
/** å‘送文本或附件(用于补充上传文件) */
export function sendAgentMessage(
  conversationId: string,
  data: AgentConversationApi.MessageReq,
) {
  return agentClient.post<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}/messages`,
    data,
  );
}
/** å¡ç‰‡æ“ä½œï¼šé€‰æ‹©åœºæ™¯ã€ç¡®è®¤æ˜ å°„、修订、确认或取消 */
export function sendAgentAction(
  conversationId: string,
  data: AgentConversationApi.ActionReq,
) {
  return agentClient.post<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}/actions`,
    data,
  );
}
/** ä¸‹è½½å¯¼å…¥æŠ¥è¡¨ï¼ˆxlsx) */
export function downloadAgentReport(conversationId: string) {
  return agentClient.download<{
    data: Blob;
    headers: Record<string, string>;
  }>(`/v1/conversations/${conversationId}/report`, {
    responseReturn: 'raw',
  });
}
/**
 * ä¸Šä¼ é™„件到 Java å­˜å‚¨å¹¶ç»‘定补录会话,返回 attachmentId。
 * Python ä¾§é€šè¿‡ Java Bridge æŒ‰ attachmentId è¯»å–文件。
 */
export async function uploadAgentAttachment(file: File) {
  const sessionId = await createImportSession();
  const blobs = await uploadFile([file]);
  const blobId = blobs?.[0]?.id;
  if (!blobId) {
    throw new Error('文件上传失败,未取得 blobId');
  }
  return await bindImportAttachment(sessionId, blobId);
}
export { agentImportErrorMessage } from '#/api/mdm/item/agent';
src/api/ai/model/model/index.ts
@@ -10,6 +10,7 @@
    model: string; // æ¨¡åž‹æ ‡è¯†
    platform: string; // æ¨¡åž‹å¹³å°
    type: number; // æ¨¡åž‹ç±»åž‹
    capability?: string; // æ¨¡åž‹èƒ½åŠ›ï¼štext-文本,vision-视觉
    sort: number; // æŽ’序
    status: number; // çŠ¶æ€
    temperature?: number; // æ¸©åº¦å‚æ•°
@@ -27,10 +28,11 @@
}
/** èŽ·å¾—æ¨¡åž‹åˆ—è¡¨ */
export function getModelSimpleList(type?: number) {
export function getModelSimpleList(type?: number, capability?: string) {
  return requestClient.get<AiModelModelApi.Model[]>('/ai/model/simple-list', {
    params: {
      type,
      capability,
    },
  });
}
@@ -50,6 +52,11 @@
  return requestClient.put('/ai/model/update', data);
}
/** ä¿®æ”¹æ¨¡åž‹çŠ¶æ€ï¼šå¯ç”¨æ—¶ä¼šè‡ªåŠ¨å…³é—­åŒç±»åž‹åŒèƒ½åŠ›çš„å…¶ä»–æ¨¡åž‹ */
export function updateModelStatus(id: number, status: number) {
  return requestClient.put('/ai/model/update-status', { id, status });
}
/** åˆ é™¤æ¨¡åž‹ */
export function deleteModel(id: number) {
  return requestClient.delete(`/ai/model/delete?id=${id}`);
src/api/bi/chart-config.ts
@@ -21,6 +21,7 @@
  export interface PageParams {
    dashboardId?: number;
    name?: string;
    pageNo?: number;
    pageSize?: number;
  }
src/api/bpm/autoApprove/index.ts
@@ -1,4 +1,5 @@
import { requestClient } from '#/api/request';
import { automaticApproval } from '#/store/audit-policy';
export namespace BpmAutoApproveApi {
  /** æµç¨‹å…å®¡æ‰¹é…ç½® */
@@ -35,9 +36,11 @@
  if (remark) {
    params.append('remark', remark);
  }
  return requestClient.put<boolean>(
  const result = await requestClient.put<boolean>(
    `/bpm/process-auto-approve/update-global?${params.toString()}`,
  );
  automaticApproval.value = autoApprove;
  return result;
}
/** æŒ‰æµç¨‹å®šä¹‰çš„ Key è®¾ç½®å…å®¡æ‰¹å¼€å…³ï¼ˆä¸å­˜åœ¨åˆ™æ–°å¢žã€å­˜åœ¨åˆ™æ›´æ–°ï¼‰ */
src/api/core/auth.ts
@@ -79,13 +79,14 @@
}
/** é€€å‡ºç™»å½• */
export async function logoutApi(accessToken: string) {
export async function logoutApi(accessToken: string, tenant?: { tenantId: number | null; visitTenantId: number | null }) {
  return baseRequestClient.post(
    '/system/auth/logout',
    {},
    {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        ...(tenant ? { 'tenant-id': tenant.tenantId, 'visit-tenant-id': tenant.visitTenantId } : {}),
      },
    },
  );
src/api/crm/contract/index.ts
@@ -93,6 +93,11 @@
  );
}
/** æŒ‰è´­é”€åˆåŒæ¨¡æ¿ä¸‹è½½ Word æ–‡ä»¶ */
export function downloadContractDocument(id: number) {
  return requestClient.download('/crm/contract/download-document', { params: { id } });
}
/** æŸ¥è¯¢åˆåŒè¯¦æƒ… */
export function getContract(id: number) {
  return requestClient.get<CrmContractApi.Contract>(
src/api/crm/customer/index.ts
@@ -18,7 +18,7 @@
    ownerUserDept?: string; // è´Ÿè´£äººçš„部门名称
    ownerUserDeptName?: string; // è´Ÿè´£äººçš„部门名称
    lockStatus?: boolean;
    dealStatus?: boolean;
    autoPoolEnabled?: boolean; // æ˜¯å¦æµå…¥å…¬æµ·ï¼Œå…³é—­æ—¶å–消公海可见度
    mobile: string; // æ‰‹æœºå·
    telephone: string; // ç”µè¯
    qq: string; // QQ
@@ -36,6 +36,8 @@
    createTime: Date; // åˆ›å»ºæ—¶é—´
    updateTime: Date; // æ›´æ–°æ—¶é—´
    poolDay?: number; // è·ç¦»è¿›å…¥å…¬æµ·å¤©æ•°
    contractCount?: number; // å·²ç­¾è®¢åˆåŒæ•°é‡ï¼ˆå®¡æ‰¹çŠ¶æ€ä¸ºå®¡æ ¸é€šè¿‡ï¼‰ï¼Œæ²¡æœ‰åˆ™ä¸º 0
    contractTotalPrice?: null | number; // å·²ç­¾è®¢åˆåŒé‡‘额合计(审核通过),没有则为 null
  }
  /** å®¢æˆ·å¯¼å…¥è¯·æ±‚ */
@@ -44,6 +46,13 @@
    file: File;
    updateSupport: boolean;
  }
  /** å®¢æˆ·å¯¼å…¥ç»“æžœ */
  export interface CustomerImportRespVO {
    createCustomerNames?: string[]; // æ–°å¢žæˆåŠŸçš„å®¢æˆ·å
    updateCustomerNames?: string[]; // æ›´æ–°æˆåŠŸçš„å®¢æˆ·å
    failureCustomerNames?: Record<string, string>; // å¯¼å…¥å¤±è´¥çš„客户名及原因
  }
}
/** æŸ¥è¯¢å®¢æˆ·åˆ—表 */
@@ -51,6 +60,21 @@
  return requestClient.get<PageResult<CrmCustomerApi.Customer>>(
    '/crm/customer/page',
    { params },
  );
}
/** æŸ¥è¯¢ä¸šåŠ¡æ¨¡å—å®¢æˆ·åˆ—è¡¨ï¼ˆä¸å— CRM è´Ÿè´£äººæƒé™è¿‡æ»¤ï¼‰ */
export function getBusinessCustomerPage(params: PageParam) {
  return requestClient.get<PageResult<CrmCustomerApi.Customer>>(
    '/crm/customer/business-page',
    { params },
  );
}
/** æŸ¥è¯¢ä¸šåŠ¡æ¨¡å—å…³è”å®¢æˆ·è¯¦æƒ…ï¼ˆä¸å— CRM è´Ÿè´£äººæƒé™è¿‡æ»¤ï¼‰ */
export function getBusinessCustomer(id: number) {
  return requestClient.get<CrmCustomerApi.Customer>(
    `/crm/customer/business-get?id=${id}`,
  );
}
@@ -88,7 +112,10 @@
/** å¯¼å…¥å®¢æˆ· */
export function importCustomer(data: CrmCustomerApi.CustomerImportReqVO) {
  return requestClient.upload('/crm/customer/import', data);
  return requestClient.upload<CrmCustomerApi.CustomerImportRespVO>(
    '/crm/customer/import',
    data,
  );
}
/** èŽ·å–å®¢æˆ·ç²¾ç®€ä¿¡æ¯åˆ—è¡¨ */
@@ -123,13 +150,6 @@
/** å®¢æˆ·æ”¾å…¥å…¬æµ· */
export function putCustomerPool(id: number) {
  return requestClient.put(`/crm/customer/put-pool?id=${id}`);
}
/** æ›´æ–°å®¢æˆ·çš„æˆäº¤çŠ¶æ€ */
export function updateCustomerDealStatus(id: number, dealStatus: boolean) {
  return requestClient.put(
    `/crm/customer/update-deal-status?id=${id}&dealStatus=${dealStatus}`,
  );
}
/** è¿›å…¥å…¬æµ·å®¢æˆ·æé†’的客户列表 */
src/api/energy/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
import type { PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export type EnergyType = 'ELECTRICITY' | 'STEAM' | 'WATER';
export interface EnergyRecord {
  id?: number;
  recordDate: string;
  energyType: EnergyType;
  usageArea: string;
  productCategory: string;
  usagePurpose: string;
  quantity: number;
  unitPrice: number;
  amount?: number;
  recorder: string;
  remark?: string;
  createTime?: string;
}
export interface EnergyFilters {
  startDate?: string;
  endDate?: string;
  energyType?: EnergyType;
  usageArea?: string;
  productCategory?: string;
}
export interface EnergySummary {
  energyType: EnergyType;
  quantity: number;
  amount: number;
  recordCount: number;
}
export interface EnergyBucket extends EnergySummary { label: string }
export interface EnergyAnalysis {
  totalAmount: number;
  recordCount: number;
  summary: EnergySummary[];
  trend: EnergyBucket[];
  areas: EnergyBucket[];
}
export function getEnergyRecordPage(params: EnergyFilters & { pageNo: number; pageSize: number }) {
  return requestClient.get<PageResult<EnergyRecord>>('/energy/record/page', { params });
}
export function getEnergyRecord(id: number) {
  return requestClient.get<EnergyRecord>('/energy/record/get', { params: { id } });
}
export function createEnergyRecord(data: EnergyRecord) {
  return requestClient.post<number>('/energy/record/create', data);
}
export function updateEnergyRecord(data: EnergyRecord) {
  return requestClient.put('/energy/record/update', data);
}
export function deleteEnergyRecord(id: number) {
  return requestClient.delete('/energy/record/delete', { params: { id } });
}
export function getEnergyAnalysis(params: EnergyFilters & { startDate: string; endDate: string; groupBy: 'DAY' | 'MONTH' }) {
  return requestClient.get<EnergyAnalysis>('/energy/record/analysis', { params });
}
export function exportEnergyRecords(params: EnergyFilters) {
  return requestClient.download('/energy/record/export-excel', { params });
}
export function exportEnergyAnalysis(params: EnergyFilters & { startDate: string; endDate: string; groupBy: 'DAY' | 'MONTH' }) {
  return requestClient.download('/energy/record/analysis-export', { params });
}
export type EnergyAlertMetric = 'AMOUNT' | 'QUANTITY' | 'MONTH_AMOUNT' | 'MONTH_QUANTITY';
export interface EnergyAlertRule {
  id?: number;
  name: string;
  energyType: EnergyType;
  usageArea?: string;
  productCategory?: string;
  metric: EnergyAlertMetric;
  threshold: number;
  enabled: boolean;
  recipientIds: number[];
}
export interface EnergyAlertLog {
  id: number; ruleName: string; recordDate: string; energyType: EnergyType;
  scope: string; metric: EnergyAlertMetric; periodType: 'DAY' | 'MONTH'; actualValue: number; threshold: number;
  recipientName: string; status: 'FAILED' | 'SENT'; errorMessage: string; createTime: string;
}
export async function getEnergyAlertRules(params: { pageNo: number; pageSize: number }) {
  const result = await requestClient.get<PageResult<Omit<EnergyAlertRule, 'recipientIds'> & { recipientIds: string }>>('/energy/alert/page', { params });
  return { ...result, list: result.list.map(row => ({ ...row, recipientIds: row.recipientIds.split(',').filter(Boolean).map(Number) })) };
}
export function getEnergyAlertLogs(params: { pageNo: number; pageSize: number; date?: string; status?: string }) {
  return requestClient.get<PageResult<EnergyAlertLog>>('/energy/alert/logs', { params });
}
export function getEnergyAlertRecipients() {
  return requestClient.get<{ id: number; nickname: string }[]>('/energy/alert/recipients');
}
export function saveEnergyAlertRule(data: EnergyAlertRule) {
  return data.id ? requestClient.put('/energy/alert/update', data) : requestClient.post('/energy/alert/create', data);
}
export function deleteEnergyAlertRule(id: number) { return requestClient.delete('/energy/alert/delete', { params: { id } }); }
export function checkEnergyAlerts(date: string) { return requestClient.post<{ sent: number; failed: number }>('/energy/alert/check', undefined, { params: { date } }); }
src/api/hrm/resignation/index.ts
@@ -58,7 +58,7 @@
/** æäº¤ç¦»èŒç”³è¯·ï¼ˆå‘起审批) */
export function submitResignation(id: number) {
  return requestClient.post(`/hrm/resignation/submit?id=${id}`);
  return requestClient.post(`/hrm/resignation/submit?id=${id}&processDefinitionKey=hrm_resignation_approve`);
}
/** æ’¤é”€ç¦»èŒç”³è¯· */
src/api/hrm/transfer/index.ts
@@ -59,7 +59,7 @@
/** æäº¤è°ƒå²—申请(发起审批) */
export function submitTransfer(id: number) {
  return requestClient.post(`/hrm/transfer/submit?id=${id}`);
  return requestClient.post(`/hrm/transfer/submit?id=${id}&processDefinitionKey=hrm_transfer_approve`);
}
/** æ’¤é”€è°ƒå²—申请 */
src/api/mdm/item/agent.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,264 @@
import type { InternalAxiosRequestConfig } from '@vben/request';
import { RequestClient } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
/**
 * Python æ™ºèƒ½ä½“服务不在 /admin-api ä¸‹ï¼Œå¼€å‘期由 Vite ä»£ç† /agent-api è½¬å‘到 127.0.0.1:8091。
 * è¯¥æœåŠ¡ç›´æŽ¥è¿”å›žä¸šåŠ¡ JSON,不使用 CommonResult åŒ…装,因此单独建客户端。
 */
const agentRequestClient = new RequestClient({
  baseURL: '/agent-api',
  responseReturn: 'body',
  timeout: 120_000,
});
agentRequestClient.addRequestInterceptor({
  fulfilled: (config: InternalAxiosRequestConfig) => {
    const token = useAccessStore().accessToken;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
});
export namespace MdmAgentImportApi {
  export type RowAction = 'CREATE' | 'SKIP' | 'BLOCKED';
  export type RowStatus = 'SUCCESS' | 'FAILED' | 'SKIPPED' | 'UNKNOWN';
  export interface Evidence {
    sheet: string;
    row: number;
    column: string;
    raw: string;
  }
  export interface Candidate {
    candidate_id: string;
    source_sha256: string;
    values: Record<string, string>;
    evidence: Record<string, Evidence>;
    issues: string[];
  }
  export interface Issue {
    code?: string;
    field?: string;
    message: string;
    retryable?: boolean;
  }
  export interface Resolved {
    category_id?: number;
    category_name?: string;
    unit_measure_id?: number;
    unit_measure_name?: string;
  }
  export interface PreflightRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: RowAction;
    resolved?: Resolved;
    blockers?: Issue[];
    warnings?: Issue[];
    target_item_id?: number;
  }
  export interface Preflight {
    preflight_hash: string;
    source: {
      attachment_id: number;
      session_id?: number;
      source_sha256: string;
      proposal_revision?: string;
    };
    policy: {
      update_allowed: boolean;
      mes_sync_allowed: boolean;
      max_rows: number;
    };
    summary: {
      total: number;
      create: number;
      skip: number;
      blocked: number;
    };
    can_commit: boolean;
    side_effects: string[];
    rows: PreflightRow[];
    blocking_items: Issue[];
  }
  export interface Correction {
    candidate_id: string;
    field: 'categoryCode' | 'code' | 'name' | 'specification' | 'unitMeasureCode';
    value: string;
    reason: string;
  }
  export interface ReceiptRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: 'CREATE' | 'SKIP';
    status: RowStatus;
    target_item_id?: number;
    target_version?: string;
    error_code?: string;
    error_message?: string;
    retryable?: boolean;
  }
  export interface Receipt {
    command_id: string;
    idempotency_key: string;
    status: 'FAILED' | 'PARTIAL_FAILED' | 'RUNNING' | 'SUCCEEDED' | 'UNKNOWN';
    attachment_id: number;
    session_id?: number;
    source_sha256: string;
    preflight_hash: string;
    proposal_hash?: string;
    summary: {
      total: number;
      success: number;
      failed: number;
      skipped: number;
      unknown: number;
    };
    error_code?: string;
    error_message?: string;
    rows: ReceiptRow[];
    create_time?: string;
    completed_time?: string;
  }
  export interface ImportState {
    task_id: string;
    attachment_id: string;
    filename: string;
    source_sha256: string;
    candidates: Candidate[];
    issues: Array<Issue & { candidate_id?: string; candidate_ids?: string[] }>;
    revision: number;
    status: string;
    preflight?: Preflight;
    preflight_hash?: string;
    proposal?: Record<string, unknown>;
    proposal_hash?: string;
    command_id?: string;
    receipt?: Receipt;
    last_error?: string;
  }
  export interface ReviewInterrupt {
    type: string;
    revision: number;
    issues: Array<Issue & { candidate_id?: string; candidate_ids?: string[] }>;
    candidates: Candidate[];
    preflight: {
      hash?: string;
      can_commit: boolean;
      summary?: Preflight['summary'];
      blocking_items: Issue[];
      needs_attention: PreflightRow[];
    };
    last_error?: string;
    notice?: string;
  }
  export interface PublicState {
    state: ImportState;
    interrupts: ReviewInterrupt[];
  }
  export interface ReviewReq {
    expected_revision: number;
    action: 'cancel' | 'confirm' | 'revise';
    corrections?: Correction[];
    expected_preflight_hash?: string;
    expected_proposal_hash?: string;
  }
}
/** åˆ›å»ºè¡¥å½•会话(Java) */
export function createImportSession() {
  return requestClient.post<number>('/agent/bridge/import-sessions');
}
/** ç»‘定附件到补录会话,返回 attachmentId(Java) */
export function bindImportAttachment(sessionId: number, blobId: number) {
  return requestClient.post<number>(
    `/agent/bridge/import-sessions/${sessionId}/attachments`,
    { blobId },
  );
}
/** åˆ›å»ºæ™ºèƒ½ä½“导入任务并触发解析与预检(Python) */
export function startMaterialImport(attachmentId: number) {
  return agentRequestClient.post<MdmAgentImportApi.PublicState>(
    '/v1/material-imports',
    { attachment_id: String(attachmentId) },
  );
}
/** è¯»å–任务当前状态(Python) */
export function getMaterialImportTask(taskId: string) {
  return agentRequestClient.get<MdmAgentImportApi.PublicState>(
    `/v1/material-imports/${taskId}`,
  );
}
/** ä¿®è®¢æˆ–确认(Python) */
export function reviewMaterialImport(
  taskId: string,
  data: MdmAgentImportApi.ReviewReq,
) {
  return agentRequestClient.post<MdmAgentImportApi.PublicState>(
    `/v1/material-imports/${taskId}/review`,
    data,
  );
}
/** æŸ¥è¯¢æäº¤å›žæ‰§ï¼ˆPython) */
export function getMaterialImportReceipt(taskId: string) {
  return agentRequestClient.get<MdmAgentImportApi.Receipt>(
    `/v1/material-imports/${taskId}/receipt`,
  );
}
interface AgentErrorDetail {
  error_code?: string;
  message?: string;
}
/** ä»Ž Python é”™è¯¯å“åº”中提取可读文案 */
export function agentImportErrorMessage(error: unknown): string {
  const err = error as
    | { data?: unknown; message?: string; response?: { data?: unknown } }
    | undefined;
  const body = (err?.response?.data ?? err?.data) as
    | AgentErrorDetail
    | string
    | undefined;
  const detail =
    typeof body === 'string'
      ? body
      : ((body as { detail?: unknown } | undefined)?.detail ?? body);
  if (typeof detail === 'string') {
    return detail;
  }
  const info = detail as AgentErrorDetail | undefined;
  if (info?.message) {
    return info.error_code
      ? `${info.message}(${info.error_code})`
      : info.message;
  }
  return err?.message || '智能体服务请求失败';
}
src/api/mes/dv/meteringcheck/index.ts
@@ -15,6 +15,7 @@
    checkResult?: number; // æ£€æµ‹ç»“论(字典)
    certNo?: string; // è¯ä¹¦ç¼–号
    checkUserId?: number; // æ£€æµ‹äººç¼–号
    checkUserName?: string; // æ‰‹å·¥å¡«å†™çš„æ£€æµ‹äººå§“名
    checkUserNickname?: string; // æ£€æµ‹äººæ˜µç§°
    nextCheckDate?: string; // ä¸‹æ¬¡æ£€æµ‹æ—¥æœŸ
    remark?: string; // å¤‡æ³¨
src/api/mes/dv/spare/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export interface SparePart {
  id: number; code: string; name: string; specification?: string; unit: string;
  location?: string; stockQuantity: number; safetyQuantity: number; status: number; remark?: string;
}
export interface SpareMovement {
  id: number; sparePartId: number; sparePartCode: string; sparePartName: string;
  specification?: string; unit: string; type: number; quantity: number;
  beforeQuantity: number; afterQuantity: number; repairId?: number; repairCode?: string;
  machineryName?: string; receiverName?: string; receiverUserId?: number; operatorUserId: number;
  originalId?: number; returnedQuantity: number; returnableQuantity: number; remark: string; createTime: string;
}
export interface SpareAction {
  requestKey: string; sparePartId?: number; repairId?: number; originalId?: number;
  receiverUserId?: number; quantity: number; remark: string;
}
export type SpareSave = Omit<SparePart, 'id' | 'stockQuantity'> & { id?: number; initialQuantity: number };
export const getSparePage = (params: PageParam & Record<string, unknown>) =>
  requestClient.get<PageResult<SparePart>>('/mes/dv/spare/page', { params });
export const getSpare = (id: number) => requestClient.get<SparePart>('/mes/dv/spare/get', { params: { id } });
export const createSpare = (data: SpareSave) => requestClient.post<number>('/mes/dv/spare/create', data);
export const updateSpare = (data: SpareSave) => requestClient.put('/mes/dv/spare/update', data);
export const deleteSpare = (id: number) => requestClient.delete('/mes/dv/spare/delete', { params: { id } });
export const getSpareMovements = (params: PageParam & Record<string, unknown>) =>
  requestClient.get<PageResult<SpareMovement>>('/mes/dv/spare/movement-page', { params });
export const spareAction = (type: 'issue' | 'receive' | 'return', data: SpareAction) =>
  requestClient.post<number>(`/mes/dv/spare/${type}`, data);
export const movementLabels: Record<number, string> = { 1: '初始库存', 2: '入库', 3: '维修领用', 4: '退回' };
src/api/mes/home/index.ts
@@ -10,6 +10,12 @@
    yesterdayOutput: number; // æ˜¨æ—¥äº§é‡
    todayQualifiedQuantity: number; // ä»Šæ—¥åˆæ ¼å“æ•°
    todayUnqualifiedQuantity: number; // ä»Šæ—¥ä¸è‰¯å“æ•°
    iqcQualifiedRate: null | number; // æ¥æ–™æ£€éªŒåˆæ ¼çއ(%),无检验数据时为 null
    ipqcQualifiedRate: null | number; // è¿‡ç¨‹æ£€éªŒåˆæ ¼çއ(%),无检验数据时为 null
    oqcQualifiedRate: null | number; // å‡ºè´§æ£€éªŒåˆæ ¼çއ(%),无检验数据时为 null
    iqcCheckQuantity: number; // æ¥æ–™æ£€éªŒçš„æ£€éªŒæ•°é‡åˆè®¡ï¼ˆåˆæ ¼çŽ‡åˆ†æ¯ï¼‰ï¼Œæ— æ•°æ®æ—¶ä¸º 0
    ipqcCheckQuantity: number; // è¿‡ç¨‹æ£€éªŒçš„æ£€éªŒæ•°é‡åˆè®¡ï¼ˆåˆæ ¼çŽ‡åˆ†æ¯ï¼‰ï¼Œæ— æ•°æ®æ—¶ä¸º 0
    oqcCheckQuantity: number; // å‡ºè´§æ£€éªŒçš„æ£€éªŒæ•°é‡åˆè®¡ï¼ˆåˆæ ¼çŽ‡åˆ†æ¯ï¼‰ï¼Œæ— æ•°æ®æ—¶ä¸º 0
    machineryTotal: number; // è®¾å¤‡æ€»æ•°
    machineryProducing: number; // ç”Ÿäº§ä¸­è®¾å¤‡æ•°
    machineryStop: number; // åœæœºè®¾å¤‡æ•°
src/api/mes/md/client/index.ts
@@ -6,7 +6,6 @@
  /** MES å®¢æˆ· */
  export interface Client {
    id?: number; // å®¢æˆ·ç¼–号
    code?: string; // å®¢æˆ·ç¼–码
    name?: string; // å®¢æˆ·åç§°
    nickname?: string; // å®¢æˆ·ç®€ç§°
    englishName?: string; // å®¢æˆ·è‹±æ–‡åç§°
@@ -31,9 +30,9 @@
  /** å®¢æˆ·å¯¼å…¥ç»“æžœ */
  export interface ClientImportRespVO {
    createCodes?: string[]; // æ–°å¢žæˆåŠŸçš„å®¢æˆ·ç¼–ç 
    updateCodes?: string[]; // æ›´æ–°æˆåŠŸçš„å®¢æˆ·ç¼–ç 
    failureCodes?: Record<string, string>; // å¯¼å…¥å¤±è´¥çš„客户编码及原因
    createCustomerNames?: string[]; // æ–°å¢žæˆåŠŸçš„å®¢æˆ·åç§°
    updateCustomerNames?: string[]; // æ›´æ–°æˆåŠŸçš„å®¢æˆ·åç§°
    failureCustomerNames?: Record<string, string>; // å¯¼å…¥å¤±è´¥çš„客户名称及原因
  }
}
src/api/mes/pd/archive/index.ts
@@ -1,4 +1,5 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
@@ -85,7 +86,8 @@
    documentName?: string;
    documentVersion?: string;
    fileName?: string;
    fileUrl?: string;
    /** èµ„料附件(走 system æ¨¡å— storage-attachment,业务表不存文件地址) */
    attachmentList?: SystemStorageApi.StorageBlob[];
    uploadUserId?: number;
    uploadUserNickname?: string;
    uploadTime?: number;
src/api/mes/pd/document-audit/index.ts
@@ -14,6 +14,10 @@
    documentName?: string;
    /** èµ„料版本(冗余) */
    documentVersion?: string;
    /** èµ„料文件名(取资料第一个附件) */
    fileName?: string;
    /** èµ„料文件地址(取资料第一个附件) */
    fileUrl?: string;
    /** å®¡æ ¸äººï¼ˆç”¨æˆ·ID) */
    reviewer?: number;
    /** å®¡æ ¸äººåç§° */
src/api/mes/pro/mps/index.ts
@@ -19,7 +19,6 @@
    quantity?: number; // è®¡åˆ’生产数量
    quantityProduced?: number; // å·²ç”Ÿäº§æ•°é‡
    clientId?: number; // å®¢æˆ·ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    requestDate?: number; // éœ€æ±‚日期
    issueDate?: number; // ä¸‹å‘日期
src/api/mes/pro/route/index.ts
@@ -61,3 +61,26 @@
export function exportRoute(params: any) {
  return requestClient.download('/mes/pro/route/export-excel', { params });
}
/** ä¸‹è½½å·¥è‰ºè·¯çº¿å¯¼å…¥æ¨¡æ¿ */
export function importRouteTemplate() {
  return requestClient.download('/mes/pro/route/get-import-template');
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
export function importRoute(file: File, updateSupport: boolean) {
  return requestClient.upload(
    `/mes/pro/route/import?updateSupport=${updateSupport}`,
    { file },
  );
}
/** ä¸‹è½½åŒ…含路线、产品、工序、工序产出、工序投料五张工作表的模板 */
export function importCompleteRouteTemplate() {
  return requestClient.download('/mes/pro/route/get-complete-import-template');
}
/** æ‰¹é‡å¯¼å…¥å®Œæ•´å·¥è‰ºè·¯çº¿ */
export function importCompleteRoute(file: File) {
  return requestClient.upload('/mes/pro/route/import-complete', { file });
}
src/api/mes/pro/route/process/index.ts
@@ -10,12 +10,26 @@
    remark?: string;
  }
  /** äº§å‡ºç‰©æ–™å½’属 */
  export interface OutputItem {
    id?: number;
    routeProcessId?: number;
    productId?: number; // å½’属产品编号,为空表示多产品通用
    productName?: string;
    itemId?: number;
    itemCode?: string;
    itemName?: string;
    sort?: number;
    remark?: string;
  }
  /** MES å·¥è‰ºè·¯çº¿å·¥åº */
  export interface RouteProcess {
    id?: number;
    routeId?: number;
    processId?: number;
    paramTemplateId?: number;
    yieldRate?: number;
    processCode?: string;
    processName?: string;
    sort?: number;
@@ -33,6 +47,13 @@
    outputItemName?: string;
    outputItemIds?: number[];
    outputItemNames?: string;
    outputs?: OutputItem[]; // äº§å‡ºç‰©æ–™å½’属配置
    warehouseId?: number; // é»˜è®¤æŠ¥å·¥ä»“库编号
    warehouseName?: string; // é»˜è®¤æŠ¥å·¥ä»“库名称
    locationId?: number; // é»˜è®¤æŠ¥å·¥åº“区编号
    locationName?: string; // é»˜è®¤æŠ¥å·¥åº“区名称
    areaId?: number; // é»˜è®¤æŠ¥å·¥åº“位编号
    areaName?: string; // é»˜è®¤æŠ¥å·¥åº“位名称
    remark?: string;
    bomItems?: ProcessBomItem[];
    createTime?: Date;
src/api/mes/pro/route/product/index.ts
@@ -8,6 +8,12 @@
    itemId?: number;
    itemCode?: string;
    itemName?: string;
    warehouseId?: number;
    warehouseName?: string;
    locationId?: number;
    locationName?: string;
    areaId?: number;
    areaName?: string;
    specification?: string;
    unitName?: string;
    quantity?: number;
src/api/mes/pro/task/index.ts
@@ -176,6 +176,55 @@
}
/** æŸ¥è¯¢å¾…报工台账(分页) */
export interface BatchScheduleRequest {
  workOrderIds: number[];
  startTime: string;
  previewHash?: string;
  previewTime?: string;
  allowLate?: boolean;
}
export interface BatchSchedulePreview {
  canExecute: boolean;
  previewHash: string;
  previewTime: string;
  warnings: string[];
  orders: {
    workOrderId: number;
    workOrderCode?: string;
    workOrderName?: string;
    quantity?: number;
    taskCount: number;
    requestDate?: string;
    endTime?: string;
    late: boolean;
    error?: string;
  }[];
  tasks: {
    workOrderId: number;
    workOrderCode: string;
    processId: number;
    processName: string;
    workstationId: number;
    workstationName: string;
    machineryNames: string;
    quantity: number;
    hourlyCapacity: number;
    duration: number;
    startTime: string;
    endTime: string;
  }[];
}
export function previewBatchSchedule(data: BatchScheduleRequest) {
  return requestClient.post<BatchSchedulePreview>('/mes/pro/task/auto-schedule/preview', data);
}
export function executeBatchSchedule(data: BatchScheduleRequest) {
  return requestClient.post<{ taskIds: number[] }>('/mes/pro/task/auto-schedule', data);
}
/** æŸ¥è¯¢å¾…报工台账(分页) */
export function getPendingFeedbackPage(
  params: MesProTaskApi.PendingFeedbackPageParams,
) {
src/api/mes/pro/workorder/index.ts
@@ -22,7 +22,6 @@
    quantityChanged?: number; // è°ƒæ•´æ•°é‡
    quantityScheduled?: number; // å·²æŽ’产数量
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    vendorId?: number; // ä¾›åº”商编号
    vendorName?: string; // ä¾›åº”商名称
@@ -35,7 +34,6 @@
    cancelDate?: number; // å–消时间
    status?: number; // è®¢å•状态
    scheduleStatus?: number; // æŽ’产状态
    productionStatus?: number; // ç”Ÿäº§çŠ¶æ€
    processProgressList?: ProcessProgress[]; // å·¥åºç”Ÿäº§è¿›åº¦
    completionProgress?: number; // å®Œæˆè¿›åº¦
    remark?: string; // å¤‡æ³¨
src/api/mes/pro/workorder/process/index.ts
@@ -1,5 +1,3 @@
import type { MesProWorkOrderProcessApi } from './index';
import { requestClient } from '#/api/request';
export namespace MesProWorkOrderProcessApi {
@@ -35,6 +33,12 @@
    outputItemName?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆç¬¬ä¸€ä¸ªï¼‰
    outputItemIds?: number[]; // äº§å‡ºäº§å“ç‰©æ–™ç¼–号列表
    outputItemNames?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆé€—号拼接)
    warehouseId?: number; // æŠ¥å·¥ä»“库编号
    warehouseName?: string; // æŠ¥å·¥ä»“库名称
    locationId?: number; // æŠ¥å·¥åº“区编号
    locationName?: string; // æŠ¥å·¥åº“区名称
    areaId?: number; // æŠ¥å·¥åº“位编号
    areaName?: string; // æŠ¥å·¥åº“位名称
    bomItems?: ProcessBomItem[]; // å·¥åºæŠ•æ–™BOM明细
    remark?: string; // å¤‡æ³¨
  }
@@ -79,4 +83,4 @@
/** åˆ é™¤å·¥å•工序 */
export function deleteWorkOrderProcess(id: number) {
  return requestClient.delete(`/mes/pro/work-order-process/delete?id=${id}`);
}
}
src/api/mes/qc/date-filter.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
import dayjs from 'dayjs';
/** æ—¥æœŸæŸ¥è¯¢åŒ…含结束日期当天的全部质检记录,分页与导出使用相同口径。 */
export function normalizeQcDateFilter<T extends object>(params: T): T {
  const normalized: Record<string, string[]> = {};
  for (const field of ['inspectDate', 'receiveDate'] as const) {
    const range = (params as { inspectDate?: string[]; receiveDate?: string[] })[field];
    if (!Array.isArray(range) || range.length !== 2) continue;
    const start = dayjs(range[0]);
    const end = dayjs(range[1]);
    if (!start.isValid() || !end.isValid()) continue;
    normalized[field] = [
      start.startOf('day').format('YYYY-MM-DD HH:mm:ss'),
      end.endOf('day').format('YYYY-MM-DD HH:mm:ss'),
    ];
  }
  return Object.keys(normalized).length > 0 ? { ...params, ...normalized } : params;
}
src/api/mes/qc/indicatorresult/index.ts
@@ -24,7 +24,6 @@
    qcId?: number; // å…³è”质检单 ID
    qcType?: number; // è´¨æ£€ç±»åž‹
    itemId?: number; // äº§å“ç‰©æ–™ ID
    sn?: string; // ç‰©èµ„ SN
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    items?: IndicatorResultDetail[]; // æ£€éªŒç»“果明细列表
src/api/mes/qc/ipqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcIpqcApi {
  /** MES è¿‡ç¨‹æ£€éªŒå• */
  export interface Ipqc {
@@ -58,7 +60,7 @@
/** æŸ¥è¯¢è¿‡ç¨‹æ£€éªŒå•分页 */
export function getIpqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcIpqcApi.Ipqc>>('/mes/qc/ipqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -89,7 +91,9 @@
/** å¯¼å‡ºè¿‡ç¨‹æ£€éªŒå• */
export function exportIpqc(params: any) {
  return requestClient.download('/mes/qc/ipqc/export-excel', { params });
  return requestClient.download('/mes/qc/ipqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
/** ä¸‹è½½è¿‡ç¨‹æ£€éªŒå•导入模板 */
src/api/mes/qc/iqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcIqcApi {
  /** MES æ¥æ–™æ£€éªŒå• */
  export interface Iqc {
@@ -14,6 +16,7 @@
    sourceLineId?: number; // æ¥æºå•据行 ID
    sourceDocCode?: string; // æ¥æºå•据编号(关联查询)
    vendorId?: number; // ä¾›åº”商 ID
    vendorName?: string; // ä¾›åº”商名称(手填)
    vendorNickname?: string; // ä¾›åº”商简称(关联查询)
    vendorBatch?: string; // ä¾›åº”商批次号
    itemId?: number; // äº§å“ç‰©æ–™ ID
@@ -52,7 +55,7 @@
/** æŸ¥è¯¢æ¥æ–™æ£€éªŒå•分页 */
export function getIqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcIqcApi.Iqc>>('/mes/qc/iqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -83,7 +86,9 @@
/** å¯¼å‡ºæ¥æ–™æ£€éªŒå• */
export function exportIqc(params: any) {
  return requestClient.download('/mes/qc/iqc/export-excel', { params });
  return requestClient.download('/mes/qc/iqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
/** ä¸‹è½½æ¥æ–™æ£€éªŒå•导入模板 */
src/api/mes/qc/ledger/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcInspectionLedgerApi {
  /** MES æ£€æµ‹ç»“果台账(一行一个检测项结果;检验单未录入检测项时该组字段为空) */
  export interface Ledger {
@@ -36,13 +38,13 @@
export function getInspectionLedgerPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcInspectionLedgerApi.Ledger>>(
    '/mes/qc/inspection-ledger/page',
    { params },
    { params: normalizeQcDateFilter(params) },
  );
}
/** å¯¼å‡ºæ£€æµ‹ç»“果台账 */
export function exportInspectionLedger(params: Record<string, unknown>) {
  return requestClient.download('/mes/qc/inspection-ledger/export-excel', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
src/api/mes/qc/oqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcOqcApi {
  /** MES å‡ºè´§æ£€éªŒå• */
  export interface Oqc {
@@ -53,7 +55,7 @@
/** æŸ¥è¯¢å‡ºè´§æ£€éªŒå•分页 */
export function getOqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcOqcApi.Oqc>>('/mes/qc/oqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -84,7 +86,9 @@
/** å¯¼å‡ºå‡ºè´§æ£€éªŒå• */
export function exportOqc(params: any) {
  return requestClient.download('/mes/qc/oqc/export-excel', { params });
  return requestClient.download('/mes/qc/oqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
/** ä¸‹è½½å‡ºè´§æ£€éªŒå•导入模板 */
src/api/mes/qc/rqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcRqcApi {
  /** MES é€€è´§æ£€éªŒå• */
  export interface Rqc {
@@ -43,7 +45,7 @@
/** æŸ¥è¯¢é€€è´§æ£€éªŒå•分页 */
export function getRqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcRqcApi.Rqc>>('/mes/qc/rqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -74,5 +76,7 @@
/** å¯¼å‡ºé€€è´§æ£€éªŒå• */
export function exportRqc(params: any) {
  return requestClient.download('/mes/qc/rqc/export-excel', { params });
  return requestClient.download('/mes/qc/rqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
src/api/mes/wm/batch/index.ts
@@ -19,7 +19,6 @@
    vendorCode?: string; // ä¾›åº”商编码
    vendorName?: string; // ä¾›åº”商名称
    clientId?: number; // å®¢æˆ· ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    purchaseOrderCode?: string; // é‡‡è´­è®¢å•编号
    salesOrderCode?: string; // é”€å”®è®¢å•编号
@@ -80,7 +79,6 @@
    salesDate?: Date; // å‡ºåº“日期
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    clientId?: number; // å®¢æˆ· ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    oqcCode?: string; // OQC æ£€éªŒå•号
    checkResult?: number; // OQC æ£€éªŒç»“论(字典 mes_qc_check_result)
src/api/mes/wm/packages/index.ts
@@ -12,7 +12,6 @@
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    invoiceCode?: string; // å‘票编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    clientNickname?: string; // å®¢æˆ·ç®€ç§°
    length?: number; // ç®±é•¿åº¦
src/api/mes/wm/productissue/index.ts
@@ -16,7 +16,6 @@
    workstationName?: string; // å·¥ä½œç«™åç§°
    workOrderId?: number; // ç”Ÿäº§è®¢å•编号
    workOrderCode?: string; // ç”Ÿäº§è®¢å•编码
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    requiredTime?: number; // éœ€æ±‚æ—¶é—´
    status?: number; // å•据状态
src/api/mes/wm/productsales/index.ts
@@ -11,7 +11,6 @@
    noticeId?: number; // å‘货通知单编号
    noticeCode?: string; // å‘货通知单编码
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    saleOrderId?: number; // é”€å”®è®¢å•ID
src/api/mes/wm/returnsales/index.ts
@@ -10,7 +10,6 @@
    name?: string; // é€€è´§å•名称
    salesOrderCode?: string; // é”€å”®è®¢å•号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    returnDate?: number; // é€€è´§æ—¥æœŸ
    returnReason?: string; // é€€è´§åŽŸå› 
src/api/mes/wm/salesnotice/index.ts
@@ -11,7 +11,6 @@
    saleOrderId?: number; // é”€å”®è®¢å•编号
    saleOrderCode?: string; // é”€å”®è®¢å•编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesDate?: number; // å‘货日期
    recipientName?: string; // æ”¶è´§äºº
src/api/request.ts
@@ -33,10 +33,13 @@
  /**
   * é‡æ–°è®¤è¯é€»è¾‘
   */
  async function doReAuthenticate() {
  async function doReAuthenticate(error?: { config?: { headers?: Record<string, unknown> } }) {
    console.warn('Access token or refresh token is invalid or expired. ');
    const accessStore = useAccessStore();
    const authStore = useAuthStore();
    const requestToken = error?.config?.headers?.Authorization;
    // å·²é€€å‡ºæˆ–已切换账号时,旧请求的 401 ä¸å¾—清除新会话。
    if (requestToken && requestToken !== formatToken(accessStore.accessToken)) return;
    accessStore.setAccessToken(null);
    if (
      preferences.app.loginExpiredMode === 'modal' &&
@@ -58,6 +61,9 @@
      throw new Error('Refresh token is null!');
    }
    const resp = await refreshTokenApi(refreshToken);
    if (accessStore.refreshToken !== refreshToken) {
      throw new Error('登录状态已变化,忽略旧会话的令牌刷新结果');
    }
    const newToken = resp?.data?.data?.accessToken;
    // add by èŠ‹è‰¿ï¼šè¿™é‡Œä¸€å®šè¦æŠ›å‡º resp.data,从而触发 authenticateResponseInterceptor ä¸­ï¼Œåˆ·æ–°ä»¤ç‰Œå¤±è´¥ï¼ï¼ï¼
    if (!newToken) {
@@ -220,13 +226,13 @@
  fulfilled: (config) => {
    const accessStore = useAccessStore();
    // æ·»åŠ ç§Ÿæˆ·ç¼–å·
    config.headers['tenant-id'] = tenantEnable
      ? accessStore.tenantId
      : undefined;
    if (!('tenant-id' in config.headers)) {
      config.headers['tenant-id'] = tenantEnable ? accessStore.tenantId : undefined;
    }
    // åªæœ‰ç™»å½•时,才设置 visit-tenant-id è®¿é—®ç§Ÿæˆ·
    config.headers['visit-tenant-id'] = tenantEnable
      ? accessStore.visitTenantId
      : undefined;
    if (!('visit-tenant-id' in config.headers)) {
      config.headers['visit-tenant-id'] = tenantEnable ? accessStore.visitTenantId : undefined;
    }
    return config;
  },
});
src/api/system/post/index.ts
@@ -13,6 +13,13 @@
    remark: string;
    createTime?: Date;
  }
  /** å²—位导入结果 */
  export interface PostImportRespVO {
    createPostNames?: string[]; // æ–°å¢žæˆåŠŸçš„å²—ä½å
    updatePostNames?: string[]; // æ›´æ–°æˆåŠŸçš„å²—ä½å
    failurePostNames?: Record<string, string>; // å¯¼å…¥å¤±è´¥çš„岗位名及原因
  }
}
/** æŸ¥è¯¢å²—位列表 */
@@ -69,8 +76,11 @@
/** å¯¼å…¥å²—位 */
export function importPost(file: File, updateSupport: boolean) {
  return requestClient.upload('/system/post/import', {
    file,
    updateSupport,
  });
  return requestClient.upload<SystemPostApi.PostImportRespVO>(
    '/system/post/import',
    {
      file,
      updateSupport,
    },
  );
}
src/api/system/user/index.ts
@@ -26,6 +26,13 @@
  export interface UserSimple extends User {
    id: number;
  }
  /** ç”¨æˆ·å¯¼å…¥ç»“æžœ */
  export interface UserImportRespVO {
    createUsernames?: string[]; // æ–°å¢žæˆåŠŸçš„ç”¨æˆ·å
    updateUsernames?: string[]; // æ›´æ–°æˆåŠŸçš„ç”¨æˆ·å
    failureUsernames?: Record<string, string>; // å¯¼å…¥å¤±è´¥çš„用户名及原因
  }
}
/** æŸ¥è¯¢ç”¨æˆ·ç®¡ç†åˆ—表 */
@@ -80,10 +87,13 @@
/** å¯¼å…¥ç”¨æˆ· */
export function importUser(file: File, updateSupport: boolean) {
  return requestClient.upload('/system/user/import', {
    file,
    updateSupport,
  });
  return requestClient.upload<SystemUserApi.UserImportRespVO>(
    '/system/user/import',
    {
      file,
      updateSupport,
    },
  );
}
/** ç”¨æˆ·å¯†ç é‡ç½® */
src/api/wls/batch/index.ts
@@ -19,7 +19,6 @@
    vendorCode?: string; // ä¾›åº”商编码
    vendorName?: string; // ä¾›åº”商名称
    clientId?: number; // å®¢æˆ· ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    purchaseOrderCode?: string; // é‡‡è´­è®¢å•编号
    salesOrderCode?: string; // é”€å”®è®¢å•编号
src/api/wls/packages/index.ts
@@ -12,7 +12,6 @@
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    invoiceCode?: string; // å‘票编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    clientNickname?: string; // å®¢æˆ·ç®€ç§°
    length?: number; // ç®±é•¿åº¦
src/api/wls/productissue/index.ts
@@ -13,7 +13,6 @@
    workstationName?: string; // å·¥ä½œç«™åç§°
    workOrderId?: number; // ç”Ÿäº§è®¢å•编号
    workOrderCode?: string; // ç”Ÿäº§è®¢å•编码
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    requiredTime?: number; // éœ€æ±‚æ—¶é—´
    status?: number; // å•据状态
src/api/wls/productsales/index.ts
@@ -11,7 +11,6 @@
    noticeId?: number; // å‘货通知单编号
    noticeCode?: string; // å‘货通知单编码
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    saleOrderId?: number; // é”€å”®è®¢å•ID
src/api/wls/returnsales/index.ts
@@ -10,7 +10,6 @@
    name?: string; // é€€è´§å•名称
    salesOrderCode?: string; // é”€å”®è®¢å•号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    returnDate?: number; // é€€è´§æ—¥æœŸ
    returnReason?: string; // é€€è´§åŽŸå› 
src/api/wls/salesnotice/index.ts
@@ -10,7 +10,6 @@
    name?: string; // é€šçŸ¥å•名称
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesDate?: number; // å‘货日期
    recipientName?: string; // æ”¶è´§äºº
src/components/crm-customer-select-dialog.vue
@@ -7,19 +7,25 @@
import { message, Modal, Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { getBusinessCustomerPage, getCustomerPage } from '#/api/crm/customer';
defineOptions({ name: 'CrmCustomerSelectDialog' });
const emit = defineEmits<{
  selected: [rows: CrmCustomerApi.Customer[]];
}>();
const props = defineProps<{ allCustomers: boolean }>();
const open = ref(false);
const multiple = ref(true);
const selectedRows = ref<CrmCustomerApi.Customer[]>([]);
const preSelectedIds = ref<number[]>([]);
const sceneType = ref('1');
function handleSceneTypeChange(key: number | string) {
  sceneType.value = key.toString();
  gridApi.query();
}
function getTableRows() {
  return gridApi.grid.getTableData().fullData as CrmCustomerApi.Customer[];
@@ -94,11 +100,6 @@
  }
}
function handleSceneTypeChange(key: number | string) {
  sceneType.value = key.toString();
  gridApi.query();
}
const gridColumns = [
  { field: 'name', title: '客户名称', minWidth: 180 },
  { field: 'mobile', title: '手机号', width: 140 },
@@ -137,12 +138,14 @@
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getCustomerPage({
          const query = {
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            sceneType: sceneType.value,
            ...formValues,
          });
          };
          return props.allCustomers
            ? await getBusinessCustomerPage(query)
            : await getCustomerPage({ ...query, sceneType: sceneType.value });
        },
      },
    },
@@ -226,7 +229,7 @@
    @ok="handleConfirm"
    @cancel="closeModal"
  >
    <Tabs class="mb-2" @change="handleSceneTypeChange">
    <Tabs v-if="!allCustomers" class="mb-2" @change="handleSceneTypeChange">
      <Tabs.TabPane key="1" tab="我负责的" />
      <Tabs.TabPane key="2" tab="我参与的" />
      <Tabs.TabPane key="3" tab="下属负责的" />
src/components/crm-customer-select.vue
@@ -2,12 +2,13 @@
import type { CrmCustomerApi } from '#/api/crm/customer';
import { computed, ref, useAttrs, watch } from 'vue';
import { useRoute } from 'vue-router';
import { IconifyIcon } from '@vben/icons';
import { Input, Tooltip } from 'ant-design-vue';
import { getCustomer } from '#/api/crm/customer';
import { getBusinessCustomer, getCustomer } from '#/api/crm/customer';
import CrmCustomerSelectDialog from './crm-customer-select-dialog.vue';
@@ -16,6 +17,7 @@
const props = withDefaults(
  defineProps<{
    allowClear?: boolean;
    allCustomers?: boolean;
    disabled?: boolean;
    modelValue?: number;
    placeholder?: string;
@@ -26,6 +28,10 @@
    modelValue: undefined,
    placeholder: '请选择客户',
  },
);
const route = useRoute();
const useAllCustomers = computed(
  () => props.allCustomers ?? !String(route.path).startsWith('/crm/'),
);
const emit = defineEmits<{
  change: [item: CrmCustomerApi.Customer | undefined];
@@ -53,7 +59,9 @@
  if (selectedItem.value?.id === id) {
    return;
  }
  selectedItem.value = await getCustomer(id);
  selectedItem.value = useAllCustomers.value
    ? await getBusinessCustomer(id)
    : await getCustomer(id);
}
watch(
@@ -129,5 +137,9 @@
      </Input>
    </Tooltip>
  </div>
  <CrmCustomerSelectDialog ref="dialogRef" @selected="handleSelected" />
  <CrmCustomerSelectDialog
    ref="dialogRef"
    :all-customers="useAllCustomers"
    @selected="handleSelected"
  />
</template>
src/components/description/description.vue
@@ -11,6 +11,9 @@
import { Card, Descriptions } from 'ant-design-vue';
import { automaticApproval } from '#/store/audit-policy';
import { isAuditField } from '#/utils/audit-ui-policy';
const props = {
  bordered: { default: true, type: Boolean },
  column: {
@@ -99,6 +102,7 @@
      const { data, schema } = unref(getProps);
      return unref(schema)
        .map((item) => {
          if (automaticApproval.value && isAuditField(item)) return null;
          const { contentMinWidth, field, render, show, span } = item;
          if (show && isFunction(show) && !show(data)) {
src/components/import-modal/import-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { VbenFormSchema } from '#/adapter/form';
import { h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Modal as AntModal, Upload } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { $t } from '#/locales';
export interface ImportModalProps {
  /** å¼¹çª—标题,如「导入用户」 */
  title: string;
  /** ä¸‹è½½çš„æ¨¡æ¿æ–‡ä»¶åï¼Œå¦‚「用户导入模板.xls」 */
  fileName: string;
  /** æ‰§è¡Œå¯¼å…¥çš„æŽ¥å£å‡½æ•° (file, updateSupport) => Promise */
  importFn: (file: File, updateSupport: boolean) => Promise<unknown>;
  /** ä¸‹è½½æ¨¡æ¿çš„æŽ¥å£å‡½æ•° () => Promise(blob) */
  templateFn: () => Promise<BlobPart>;
  /** å®Œæ•´è·¯çº¿åªæ”¯æŒæ–°å¢žï¼Œä¸å±•示覆盖开关 */
  showUpdateSupport?: boolean;
}
const emit = defineEmits(['success']);
const config = ref<ImportModalProps>({
  title: '',
  fileName: '',
  importFn: async () => {},
  templateFn: () => Promise.resolve(new Blob()),
});
function createSchema(showUpdateSupport = true): VbenFormSchema[] {
  return [
    {
      fieldName: 'file',
      label: '数据文件',
      component: 'Upload' as const,
      rules: 'required',
      help: '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶',
    },
    ...(showUpdateSupport ? [{
      fieldName: 'updateSupport',
      label: '是否覆盖',
      component: 'Switch' as const,
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
      help: '是否更新已经存在的数据',
    }] : []),
  ];
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
    formItemClass: 'col-span-2',
    labelWidth: 120,
  },
  layout: 'horizontal',
  schema: createSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (isOpen) {
      config.value = modalApi.getData<ImportModalProps>() ?? config.value;
      await formApi.updateSchema(createSchema(config.value.showUpdateSupport !== false));
      await formApi.resetForm();
    }
  },
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      const result = await config.value.importFn(data.file, data.updateSupport ?? false) as {
        createCodes?: string[];
        updateCodes?: string[];
        failureCodes?: Record<string, string>;
      } | undefined;
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
      const failures = Object.entries(result?.failureCodes ?? {});
      if (failures.length > 0) {
        AntModal.warning({
          title: `导入完成:成功 ${(result?.createCodes?.length ?? 0) + (result?.updateCodes?.length ?? 0)} æ¡ï¼Œå¤±è´¥ ${failures.length} æ¡`,
          content: h('div', { style: { whiteSpace: 'pre-wrap', maxHeight: '50vh', overflowY: 'auto' } },
            failures.map(([code, reason]) => `${code}:${reason}`).join('\n')),
        });
      } 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 config.value.templateFn();
  downloadFileFromBlobPart({ fileName: config.value.fileName, source: data });
}
</script>
<template>
  <Modal :title="config.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>
    <template #prepend-footer>
      <div class="flex flex-auto items-center">
        <Button @click="handleDownload"> ä¸‹è½½å¯¼å…¥æ¨¡æ¿ </Button>
      </div>
    </template>
  </Modal>
</template>
src/components/import-modal/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
export { default } from './import-modal.vue';
src/components/operate-log/operate-log.vue
@@ -3,7 +3,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel, getDictObj } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { Tag, Timeline } from 'ant-design-vue';
@@ -11,6 +11,7 @@
withDefaults(defineProps<OperateLogProps>(), {
  logList: () => [],
  dateFormat: 'YYYY-MM-DD HH:mm:ss',
});
/** èŽ·å¾— userType é¢œè‰² */
@@ -48,7 +49,7 @@
        </template>
        <div class="ml-2 flex flex-wrap items-center gap-2 leading-[22px]">
          <span class="w-[140px] shrink-0 text-[13px] text-gray-400">
            {{ formatDateTime(log.createTime) }}
            {{ formatDate(log.createTime, dateFormat) }}
          </span>
          <Tag color="success" class="!mr-0">{{ log.userName }}</Tag>
          <span>{{ log.action }}</span>
src/components/operate-log/typing.ts
@@ -1,5 +1,7 @@
import type { SystemOperateLogApi } from '#/api/system/operate-log';
export interface OperateLogProps {
  /** ä»…控制日期展示,不改变日志原始时间。 */
  dateFormat?: string;
  logList: SystemOperateLogApi.OperateLog[]; // æ“ä½œæ—¥å¿—列表
}
src/components/quality/engine/report-evaluator.ts
@@ -197,6 +197,17 @@
      return;
    }
    const hasLimit = item.upperLimit != null || item.lowerLimit != null;
    if (
      hasLimit &&
      (!String(item.actualValue ?? '').trim() || !Number.isFinite(toNumber(item.actualValue)))
    ) {
      applyResult(item, undefined);
      errors.push(
        `第 ${index + 1} é¡¹ã€Œ${item.itemName ?? ''}」缺少有效数值结果,待补测或确认原始记录`,
      );
      return;
    }
    const byLimit = evaluateByLimit(item);
    if (byLimit !== undefined) {
      applyResult(item, byLimit ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL);
@@ -274,4 +285,4 @@
  const node = parseRule(expression);
  ruleCache.set(expression, node);
  return node;
}
}
src/components/table-action/table-action.vue
@@ -20,6 +20,9 @@
  Tooltip,
} from 'ant-design-vue';
import { automaticApproval } from '#/store/audit-policy';
import { auditActionMode } from '#/utils/audit-ui-policy';
const props = defineProps({
  actions: {
    type: Array as PropType<ActionItem[]>,
@@ -43,6 +46,7 @@
/** æ£€æŸ¥æ˜¯å¦æ˜¾ç¤º */
function isIfShow(action: ActionItem): boolean {
  if (auditActionMode(action.label, automaticApproval.value) === 'hidden') return false;
  const ifShow = action.ifShow;
  let isIfShow = true;
  if (isBoolean(ifShow)) {
@@ -61,15 +65,21 @@
/** å¤„理按钮 actions */
const getActions = computed(() => {
  const actions = props.actions || [];
  return actions.filter((action: ActionItem) => isIfShow(action));
  return actions.filter((action: ActionItem) => isIfShow(action)).map(displayAction);
});
/** å¤„理下拉菜单 actions */
const getDropdownList = computed(() => {
  const dropDownActions = props.dropDownActions || [];
  return dropDownActions.filter((action: ActionItem) => isIfShow(action));
  return dropDownActions.filter((action: ActionItem) => isIfShow(action)).map(displayAction);
});
function displayAction(action: ActionItem): ActionItem {
  return auditActionMode(action.label, automaticApproval.value) === 'activate'
    ? { ...action, label: '生效', tooltip: '提交后自动生效' }
    : action;
}
/** Space ç»„ä»¶çš„ size */
const spaceSize = computed(() => {
  const actions = unref(getActions);
src/layouts/auth.vue
@@ -18,11 +18,19 @@
const appName = computed(() => preferences.copyright.companyName);
// ç™»å½•页 logo ä¼˜å…ˆå–数据库配置(preferences.logo.source),未配置时回退编译期默认 __APP_LOGO__
const logo = computed(() => preferences.logo.source || __APP_LOGO__);
const logo = computed(() => {
  const source = preferences.logo.source || __APP_LOGO__;
  // æœªé…ç½®å…¬å¸æ ‡å¿—时不展示框架演示 Logo。
  return source?.includes('@vbenjs/static-source') ? '' : source;
});
const logoDark = computed(() => preferences.logo.sourceDark);
// ç™»å½•页标题/描述使用 i18n å›ºå®šæ–‡æ¡ˆï¼Œä¸éšå…¬å¸é…ç½®å˜åŒ–
const pageTitle = computed(() => $t('authentication.pageTitle'));
const pageDescription = computed(() => $t('authentication.pageDesc'));
// æ ‡é¢˜/描述优先取公司信息配置,未配置时回退标品文案
const pageTitle = computed(
  () => companyConfigStore.loginTitle || $t('authentication.pageTitle'),
);
const pageDescription = computed(
  () => companyConfigStore.loginDescription || $t('authentication.pageDesc'),
);
</script>
<template>
src/main.ts
@@ -24,6 +24,22 @@
    overrides: overridesPreferences,
  });
  // æ¢å¤æ ‡å“é»˜è®¤è“è‰²ä¸»é¢˜ï¼šæ¸…掉之前缓存的朱砂红品牌色。
  // ä¸€æ¬¡æ€§è¿ç§»ï¼Œä¹‹åŽç”¨æˆ·åœ¨åå¥½è®¾ç½®ä¸­é€‰æ‹©çš„颜色仍会保留。
  const themeMigrationKey = `${namespace}-default-blue-theme-v1`;
  const defaultTheme = {
    theme: { builtinType: 'default' as const, colorPrimary: 'hsl(212 100% 45%)' },
  };
  try {
    if (!localStorage.getItem(themeMigrationKey)) {
      preferencesManager.updatePreferences(defaultTheme);
      localStorage.setItem(themeMigrationKey, '1');
      localStorage.removeItem(`${namespace}-herbal-theme-v1`);
    }
  } catch {
    preferencesManager.updatePreferences(defaultTheme);
  }
  // å¼ºåˆ¶è¦†ç›–公司 logo å’Œ favicon,绕过 localStorage ç¼“å­˜
  if (__APP_LOGO__) {
    preferencesManager.updatePreferences({ logo: { source: __APP_LOGO__ } });
src/packages/@core/base/shared/dist/cache/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/cache/index.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/color/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/color/index.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/constants/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/constants/index.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/global-state.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/global-state.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/store.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/store.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/utils/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/utils/index.mjs
ÎļþÒÑɾ³ý
src/packages/@core/ui-kit/shadcn-ui/src/components/input-password/input-password.vue
@@ -42,16 +42,21 @@
        <slot name="strengthText"> </slot>
      </p>
    </template>
    <div
    <button
      type="button"
      :aria-label="show ? '隐藏密码' : '显示密码'"
      :aria-pressed="show"
      :disabled="!!$attrs.disabled"
      :class="{
        'top-3': !!passwordStrength,
        'top-1/2 -translate-y-1/2 items-center': !passwordStrength,
      }"
      class="text-foreground/60 hover:text-foreground absolute inset-y-0 right-0 flex cursor-pointer pr-3 text-lg leading-5"
      @click="show = !show"
      @keydown.enter.stop
    >
      <Eye v-if="show" class="size-4" />
      <EyeOff v-else class="size-4" />
    </div>
    </button>
  </div>
</template>
src/packages/constants/src/biz-ai-enum.ts
@@ -24,6 +24,12 @@
  RERANK: 6, // é‡æŽ’
};
/** AI æ¨¡åž‹èƒ½åŠ›ï¼šä¸Žæ¨¡åž‹ç±»åž‹ä¸€èµ·å†³å®šã€Œå¯ç”¨å”¯ä¸€ã€åˆ†ç»„ */
export const AiModelCapabilityEnum = {
  TEXT: 'text', // æ–‡æœ¬
  VISION: 'vision', // è§†è§‰
};
export interface ImageModel {
  image?: string;
  key: string;
src/packages/constants/src/biz-mes-enum.ts
@@ -147,7 +147,6 @@
  DV_MACHINERY_CODE: 'DV_MACHINERY_CODE',
  DV_REPAIR_CODE: 'DV_REPAIR_CODE',
  DV_SUBJECT_CODE: 'DV_SUBJECT_CODE',
  MD_CLIENT_CODE: 'MD_CLIENT_CODE',
  MD_ITEM_TYPE_CODE: 'MD_ITEM_TYPE_CODE',
  MD_ITEM_CODE: 'MD_ITEM_CODE',
  MD_VENDOR_CODE: 'MD_VENDOR_CODE',
@@ -168,6 +167,7 @@
  QC_INDICATOR_CODE: 'QC_INDICATOR_CODE',
  QC_INDICATOR_RESULT_CODE: 'QC_INDICATOR_RESULT_CODE',
  QC_IQC_CODE: 'QC_IQC_CODE',
  QC_IQC_VENDOR_BATCH_CODE: 'QC_IQC_VENDOR_BATCH_CODE',
  QC_IPQC_CODE: 'QC_IPQC_CODE',
  QC_OQC_CODE: 'QC_OQC_CODE',
  QC_RQC_CODE: 'QC_RQC_CODE',
@@ -242,13 +242,6 @@
  CONFIRMED: 1, // å·²ç¡®è®¤
  FINISHED: 2, // å·²å®Œæˆ
  CANCELED: 3, // å·²å–消
} as const;
/** MES ç”Ÿäº§å·¥å•生产状态枚举(对齐后端 MesProWorkOrderProductionStatusEnum) */
export const MesProWorkOrderProductionStatusEnum = {
  WAITING: 0, // å¾…生产
  PRODUCING: 1, // ç”Ÿäº§ä¸­
  FINISHED: 2, // ç”Ÿäº§å®Œæˆ
} as const;
/** MES å·¥å•类型枚举 */
src/packages/constants/src/dict-enum.ts
@@ -148,6 +148,7 @@
const AI_DICT = {
  AI_GENERATE_MODE: 'ai_generate_mode', // AI ç”Ÿæˆæ¨¡å¼
  AI_IMAGE_STATUS: 'ai_image_status', // AI å›¾ç‰‡çŠ¶æ€
  AI_MODEL_CAPABILITY: 'ai_model_capability', // AI æ¨¡åž‹èƒ½åŠ›
  AI_MODEL_TYPE: 'ai_model_type', // AI æ¨¡åž‹ç±»åž‹
  AI_MUSIC_STATUS: 'ai_music_status', // AI éŸ³ä¹çŠ¶æ€
  AI_PLATFORM: 'ai_platform', // AI å¹³å°
src/packages/effects/common-ui/src/ui/authentication/login.vue
@@ -68,6 +68,7 @@
const rememberMe = ref(!!localUsername);
async function handleSubmit() {
  if (props.loading) return;
  const { valid } = await formApi.validate();
  const values = await formApi.getValues();
  if (valid) {
src/packages/effects/hooks/src/use-app-config.ts
@@ -46,3 +46,8 @@
export function isDocAlertEnable(): boolean {
  return import.meta.env.VITE_APP_DOCALERT_ENABLE !== 'false';
}
/** AI åŠŸèƒ½æ€»å¼€å…³ï¼šå…³é—­åŽéšè—æ‰€æœ‰ AI å…¥å£ï¼ˆæ¨¡å—菜单、OCR、预测、智能判定、智能体导入等) */
export function isAiEnable(): boolean {
  return import.meta.env.VITE_APP_AI_ENABLE !== 'false';
}
src/packages/effects/hooks/src/use-design-tokens.ts
@@ -24,6 +24,7 @@
    colorPrimary: '',
    colorSuccess: '',
    colorTextBase: '',
    colorTextLightSolid: '',
    colorWarning: '',
    zIndexPopupBase: 2000, // è°ƒæ•´åŸºç¡€å¼¹å±‚层级,避免下拉等组件被弹窗或者最大化状态下的表格遮挡
  });
@@ -48,7 +49,7 @@
      tokens.colorTextBase = getCssVariableValue('--foreground');
      getCssVariableValue('--primary-foreground');
      tokens.colorTextLightSolid = getCssVariableValue('--primary-foreground');
      tokens.colorBorderSecondary = tokens.colorBorder =
        getCssVariableValue('--border');
src/packages/effects/request/src/request-client/preset-interceptors.ts
@@ -52,7 +52,7 @@
  formatToken,
}: {
  client: RequestClient;
  doReAuthenticate: () => Promise<void>;
  doReAuthenticate: (error?: any) => Promise<void>;
  doRefreshToken: () => Promise<string>;
  enableRefreshToken: boolean;
  formatToken: (token: string) => null | string;
@@ -67,13 +67,14 @@
      // åˆ¤æ–­æ˜¯å¦å¯ç”¨äº† refreshToken åŠŸèƒ½
      // å¦‚果没有启用或者已经是重试请求了,直接跳转到重新登录
      if (!enableRefreshToken || config.__isRetryRequest) {
        await doReAuthenticate();
        await doReAuthenticate(error);
        throw error;
      }
      // å¦‚果正在刷新 token,则将请求加入队列,等待刷新完成
      if (client.isRefreshing) {
        return new Promise((resolve) => {
          client.refreshTokenQueue.push((newToken: string) => {
        return new Promise((resolve, reject) => {
          client.refreshTokenQueue.push((newToken: string, refreshError?: unknown) => {
            if (refreshError) { reject(refreshError); return; }
            config.headers.Authorization = formatToken(newToken);
            resolve(client.request(config.url, { ...config }));
          });
@@ -96,10 +97,10 @@
        return client.request(error.config.url, { ...error.config });
      } catch (refreshError) {
        // å¦‚果刷新 token å¤±è´¥ï¼Œå¤„理错误(如强制登出或跳转登录页面)
        client.refreshTokenQueue.forEach((callback) => callback(''));
        client.refreshTokenQueue.forEach((callback) => callback('', refreshError));
        client.refreshTokenQueue = [];
        console.error('Refresh token failed, please login again.');
        await doReAuthenticate();
        await doReAuthenticate(error);
        throw refreshError;
      } finally {
src/packages/effects/request/src/request-client/request-client.ts
@@ -47,7 +47,7 @@
  public isRefreshing = false;
  public postSSE: SSE['postSSE'];
  // åˆ·æ–°token队列
  public refreshTokenQueue: ((token: string) => void)[] = [];
  public refreshTokenQueue: ((token: string, error?: unknown) => void)[] = [];
  public requestSSE: SSE['requestSSE'];
  public upload: FileUploader['upload'];
src/packages/stores/src/setup.ts
@@ -76,6 +76,11 @@
  }
  const allStores = (pinia as any)._s;
  for (const [_key, store] of allStores) {
    store.$reset();
    try {
      store.$reset();
    } catch (error) {
      // å•个业务 Store çš„异常不能中断认证清理和退出登录跳转。
      console.error(`Failed to reset store: ${_key}`, error);
    }
  }
}
src/router/routes/modules/ai.ts
@@ -1,6 +1,9 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
import { isAiEnable } from '@vben/hooks';
/** AI æ€»å¼€å…³å…³é—­æ—¶æ•´ç»„路由不注册,避免通过地址栏直接访问 */
const routes: RouteRecordRaw[] = isAiEnable() ? [
  {
    path: '/ai',
    name: 'Ai',
@@ -108,6 +111,6 @@
      },
    ],
  },
];
] : [];
export default routes;
src/store/audit-policy.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
import { ref } from 'vue';
import { requestClient } from '#/api/request';
export const automaticApproval = ref(false);
/** Loaded with each authenticated session, before business pages are rendered. */
export async function loadAuditPolicy() {
  try {
    automaticApproval.value = await requestClient.get<boolean>('/bpm/process-auto-approve/ui-policy');
  } catch {
    // A failed policy request must not remove the normal submission path.
    automaticApproval.value = false;
  }
}
src/store/auth.ts
@@ -6,6 +6,7 @@
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { isAiEnable } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
@@ -21,6 +22,23 @@
  socialLogin,
} from '#/api';
import { $t } from '#/locales';
import { automaticApproval, loadAuditPolicy } from '#/store/audit-policy';
/** AI æ€»å¼€å…³å…³é—­æ—¶ï¼Œè¿žåŒ /ai æ¨¡å—菜单一并隐藏 */
function filterAiMenus<T extends { children?: T[]; path?: string }>(
  menus: T[],
): T[] {
  return menus
    .filter((menu) => {
      const path = menu.path?.replace(/^\//, '');
      return !(path === 'ai' || path?.startsWith('ai/'));
    })
    .map((menu) =>
      menu.children?.length
        ? { ...menu, children: filterAiMenus(menu.children) }
        : menu,
    );
}
export const useAuthStore = defineStore('auth', () => {
  const accessStore = useAccessStore();
@@ -28,6 +46,8 @@
  const router = useRouter();
  const loginLoading = ref(false);
  let sessionVersion = 0;
  let logoutTask: Promise<void> | null = null;
  /**
   * å¼‚步处理登录操作
@@ -43,6 +63,7 @@
  ) {
    // å¼‚步处理用户登录操作并获取 accessToken
    let userInfo: null | UserInfo = null;
    const version = ++sessionVersion;
    try {
      let loginResult: AuthApi.LoginResult;
      loginLoading.value = true;
@@ -64,6 +85,7 @@
        }
      }
      const { accessToken, refreshToken } = loginResult;
      if (version !== sessionVersion) return { userInfo: null };
      // å¦‚果成功获取到 accessToken
      if (accessToken) {
@@ -77,6 +99,7 @@
        //   // getAccessCodesApi(),
        // ]);
        const fetchUserInfoResult = await fetchUserInfo();
        if (version !== sessionVersion) return { userInfo: null };
        userInfo = fetchUserInfoResult.user;
@@ -97,8 +120,11 @@
          });
        }
      }
    } catch (error) {
      if (version === sessionVersion) throw error;
      return { userInfo: null };
    } finally {
      loginLoading.value = false;
      if (version === sessionVersion) loginLoading.value = false;
    }
    return {
@@ -106,16 +132,28 @@
    };
  }
  async function logout(redirect: boolean = true) {
  function logout(redirect: boolean = true): Promise<void> {
    if (logoutTask) return logoutTask;
    const accessToken = accessStore.accessToken;
    const tenant = { tenantId: accessStore.tenantId, visitTenantId: accessStore.visitTenantId };
    const fullPath = router.currentRoute.value.fullPath;
    ++sessionVersion;
    // æœåŠ¡å™¨æ³¨é”€ç‹¬ç«‹æ‰§è¡Œï¼Œæœ¬åœ°æ¸…ç†ä¸Žè·³è½¬ä¸ç­‰å¾…ç½‘ç»œã€‚
    // ä½¿ç”¨é€€å‡ºå‰çš„ token å’Œç§Ÿæˆ·å¿«ç…§ï¼Œé¿å…æ¸…理后丢失注销上下文。
    if (accessToken) void logoutApi(accessToken, tenant).catch(() => {});
    logoutTask = clearSessionAndNavigate(redirect, fullPath).finally(() => { logoutTask = null; });
    return logoutTask;
  }
  async function clearSessionAndNavigate(redirect: boolean, fullPath: string) {
    try {
      const accessToken = accessStore.accessToken as string;
      if (accessToken) {
        await logoutApi(accessToken);
      }
    } catch {
      // ä¸åšä»»ä½•处理
      resetAllStores();
    } catch (error) {
      console.error('Failed to reset business stores during logout', error);
    }
    resetAllStores();
    // è®¤è¯æ¸…理不能依赖业务 store çš„重置结果。
    accessStore.$reset();
    userStore.$reset();
    accessStore.setLoginExpired(false);
    // å›žç™»å½•页带上当前路由地址
@@ -123,28 +161,40 @@
      path: LOGIN_PATH,
      query: redirect
        ? {
            redirect: encodeURIComponent(router.currentRoute.value.fullPath),
            redirect: encodeURIComponent(fullPath),
          }
        : {},
    });
  }
  async function fetchUserInfo() {
    const version = sessionVersion;
    const accessToken = accessStore.accessToken;
    // åŠ è½½
    // eslint-disable-next-line no-useless-assignment
    let authPermissionInfo: AuthPermissionInfo | null = null;
    authPermissionInfo = await getAuthPermissionInfoApi();
    if (version !== sessionVersion || accessToken !== accessStore.accessToken) {
      throw new Error('登录状态已变化,忽略旧会话的用户信息');
    }
    await loadAuditPolicy();
    if (version !== sessionVersion || accessToken !== accessStore.accessToken) {
      throw new Error('登录状态已变化,忽略旧会话的用户信息');
    }
    // userStore
    userStore.setUserInfo(authPermissionInfo.user);
    userStore.setUserRoles(authPermissionInfo.roles);
    // accessStore
    accessStore.setAccessMenus(authPermissionInfo.menus);
    accessStore.setAccessMenus(
      isAiEnable() ? authPermissionInfo.menus : filterAiMenus(authPermissionInfo.menus),
    );
    accessStore.setAccessCodes(authPermissionInfo.permissions);
    return authPermissionInfo;
  }
  function $reset() {
    loginLoading.value = false;
    automaticApproval.value = false;
  }
  return {
src/store/company-config.ts
@@ -59,7 +59,15 @@
    applyToPreferences();
  }
  function $reset() {
    companyName.value = '';
    logoUrl.value = '';
    loginTitle.value = '';
    loginDescription.value = '';
  }
  return {
    $reset,
    companyName,
    logoUrl,
    loginTitle,
src/utils/audit-ui-policy.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
/** Review-only UI disappears in automatic mode; document status and activation remain. */
export function isAuditField(item: { field?: string; fieldName?: string; label?: unknown; title?: unknown }) {
  return /^(auditStatus|auditUserId|auditTime|approvalStatus|approvalUserId|approvalTime|processInstanceId)$/.test(item.field ?? item.fieldName ?? '')
    || /^(审批状态|审核状态|审批人|审核人|审批时间|审核时间|流程实例)$/.test(String(item.title ?? item.label ?? ''));
}
export function auditActionMode(label: string | undefined, automatic: boolean) {
  if (!automatic) return 'normal';
  if (/^(提交审批|提交审核|审核|审批|通过|审核通过)$/.test(label ?? '')) return 'activate';
  if (/^(查看审批|查看审核|审批进度|查看审批进度|反审核|撤销审批|取消审批|驳回|拒绝)$/.test(label ?? '')) return 'hidden';
  return 'normal';
}
src/utils/contract-document.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
import { downloadFileFromBlobPart } from '@vben/utils';
import { downloadContractDocument } from '#/api/crm/contract';
export async function downloadContractWord(id: number, no?: string) {
  const source = await downloadContractDocument(id);
  const suffix = (no || String(id)).replaceAll(/[\\/:*?"<>|]/g, '_');
  downloadFileFromBlobPart({ fileName: `购销合同-${suffix}.docx`, source });
}
src/utils/import-result.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
import type { VNodeChild } from 'vue';
import { h } from 'vue';
import { message, notification } from 'ant-design-vue';
export interface ImportResultNotifyOptions {
  /** ä¸šåŠ¡ä¸»ä½“ï¼Œç”¨äºŽæ‹¼æ ‡é¢˜ä¸Žå»ºè®®ï¼Œå¦‚ã€Œè®¾å¤‡ã€ã€Œå®¢æˆ·ã€ */
  subject: string;
  /** æ–°å¢žæˆåŠŸçš„æ¡æ•° */
  createCount?: number;
  /** æ›´æ–°æˆåŠŸçš„æ¡æ•° */
  updateCount?: number;
  /** å¤±è´¥æ˜Žç»†ï¼škey ä¸ºç¼–码/名称,value ä¸ºåŽç«¯å¤±è´¥åŽŸå›  */
  failures?: null | Record<string, string>;
  /** ã€Œä¸å­˜åœ¨ã€ç±»å¤±è´¥çš„落点提示,如「【设备类型】【车间】【工作站】中的对应编码」 */
  masterDataHint?: string;
}
/**
 * ç»Ÿä¸€å±•示 Excel å¯¼å…¥ç»“果。
 *
 * åªè¦åŽç«¯è¿”回了失败明细,就绝不能再提示「操作成功」——改为常驻通知逐行列出失败原因,
 * å¹¶æŒ‰å¤±è´¥åŽŸå› ç»™å‡ºä¸‹ä¸€æ­¥åŠ¨ä½œï¼›å…¨éƒ¨æˆåŠŸæ—¶æ‰æç¤ºæˆåŠŸã€‚
 *
 * @returns å®žé™…写入的条数(新增 + æ›´æ–°ï¼‰ï¼Œä¾›è°ƒç”¨æ–¹å†³å®šæ˜¯å¦éœ€è¦åˆ·æ–°åˆ—表
 */
export function notifyImportResult(
  options: ImportResultNotifyOptions,
): number {
  const {
    subject,
    createCount = 0,
    updateCount = 0,
    failures,
    masterDataHint,
  } = options;
  const failureEntries = Object.entries(failures ?? {});
  const importedCount = createCount + updateCount;
  if (failureEntries.length === 0) {
    message.success(`导入成功:新增 ${createCount} æ¡ï¼Œæ›´æ–° ${updateCount} æ¡`);
    return importedCount;
  }
  const nodes: VNodeChild[] = [
    h(
      'div',
      `新增成功 ${createCount} æ¡ï¼Œæ›´æ–°æˆåŠŸ ${updateCount} æ¡ï¼Œå¤±è´¥ ${failureEntries.length} æ¡ï¼š`,
    ),
    ...failureEntries.map(([name, reason]) => h('div', `${name}:${reason}`)),
    ...buildFailureHints(failureEntries.map(([, reason]) => reason), {
      subject,
      masterDataHint,
    }).map((hint) => h('div', { class: 'mt-2 text-red-500' }, hint)),
  ];
  const notify = importedCount === 0 ? notification.error : notification.warning;
  notify({
    message:
      importedCount === 0
        ? `${subject}导入失败:${failureEntries.length} è¡Œå…¨éƒ¨æœªå¯¼å…¥`
        : `导入完成,部分${subject}导入失败(成功 ${importedCount} æ¡ / å¤±è´¥ ${failureEntries.length} æ¡ï¼‰`,
    description: () => h('div', nodes),
    duration: null,
  });
  return importedCount;
}
/** æŒ‰å¤±è´¥åŽŸå› å½’çº³ä¸‹ä¸€æ­¥åŠ¨ä½œï¼Œé¿å…ç”¨æˆ·åªçœ‹åˆ°ã€Œå¤±è´¥ã€å´ä¸çŸ¥é“æ”¹å“ªé‡Œ */
function buildFailureHints(
  reasons: string[],
  { subject, masterDataHint }: Pick<ImportResultNotifyOptions, 'masterDataHint' | 'subject'>,
): string[] {
  const hints = new Set<string>();
  for (const reason of reasons) {
    if (reason.includes('不存在')) {
      hints.add(
        `提示「不存在」的:请先维护好${masterDataHint ?? '对应主数据'},或把 Excel ä¸­çš„值改成已存在的,再重新导入。`,
      );
    } else if (reason.includes('已经存在') || reason.includes('已存在')) {
      hints.add(
        `提示「已存在」的:请修改 Excel ä¸­çš„${subject}名称或编码,或勾选「支持更新」后重新导入以覆盖已有数据。`,
      );
    } else if (reason.includes('不能为空') || reason.includes('为空')) {
      hints.add('提示「不能为空」的:请补齐 Excel ä¸­å¯¹åº”列的内容后重新导入。');
    }
  }
  return [...hints];
}
src/utils/index.ts
@@ -1,5 +1,6 @@
import type { Recordable } from '@vben/types';
export * from './import-result';
export * from './rangePickerProps';
export * from './routerHelper';
src/utils/rangePickerProps.ts
@@ -66,3 +66,13 @@
    },
  };
}
/** æŒ‰ä¸šåŠ¡æ—¥æœŸç­›é€‰ï¼ŒæŽ¥å£ä»æŽ¥æ”¶å®Œæ•´æ—¶é—´å­—ç¬¦ä¸²ã€‚ */
export function getDateRangePickerDefaultProps() {
  return {
    ...getRangePickerDefaultProps(),
    format: 'YYYY-MM-DD',
    placeholder: ['开始日期', '结束日期'],
    showTime: false,
  };
}
src/views/_core/authentication/login.vue
@@ -97,6 +97,8 @@
      component: 'VbenInput',
      componentProps: {
        placeholder: $t('authentication.usernameTip'),
        autocomplete: 'username',
        'aria-label': $t('authentication.username'),
      },
      fieldName: 'username',
      label: $t('authentication.username'),
@@ -109,6 +111,8 @@
      component: 'VbenInputPassword',
      componentProps: {
        placeholder: $t('authentication.passwordTip'),
        autocomplete: 'current-password',
        'aria-label': $t('authentication.password'),
      },
      fieldName: 'password',
      label: $t('authentication.password'),
src/views/agent/dataEntry/components/TaskCenterDrawer.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { AgentTaskApi } from '#/api/agent/conversation';
import { computed, onBeforeUnmount, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
  agentImportErrorMessage,
  cancelAgentTask,
  listAgentTasks,
  streamAgentTaskEvents,
} from '#/api/agent/conversation';
defineOptions({ name: 'AgentTaskCenterDrawer' });
interface Emits {
  (event: 'continue', task: AgentTaskApi.Task): void;
}
const props = defineProps<{ enabled: boolean }>();
const emit = defineEmits<Emits>();
const visible = ref(false);
const loading = ref(false);
const tasks = ref<AgentTaskApi.Task[]>([]);
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let eventAbort: AbortController | undefined;
const activeCount = computed(
  () => tasks.value.filter((task) => !isTerminal(task.status)).length,
);
function isTerminal(status: string) {
  return ['cancelled', 'failed', 'partially_succeeded', 'succeeded'].includes(
    status,
  );
}
function statusText(status: string) {
  const values: Record<string, string> = {
    cancelled: '已取消',
    failed: '失败',
    partially_succeeded: '部分完成',
    queued: '排队中',
    retry_wait: '等待重试',
    running: '执行中',
    succeeded: '已完成',
    waiting_approval: '待审批',
    waiting_input: '待补充',
  };
  return values[status] ?? status;
}
function statusColor(status: string) {
  if (status === 'succeeded') return 'green';
  if (status === 'failed' || status === 'cancelled') return 'red';
  if (status.startsWith('waiting_') || status === 'retry_wait') return 'orange';
  return 'blue';
}
function stageText(stage: string) {
  const values: Record<string, string> = {
    attachment_download: '读取企业附件',
    cancelled: '任务已取消',
    document_invalid: '文档校验失败',
    document_parse: '解析与结构识别',
    evidence_persist: '保存解析证据',
    failed: '任务失败',
    queued: '等待 Worker',
    reauth_required: '需要重新授权',
    result_ready: '分析结果已生成',
    retry_wait: '等待自动重试',
    starting: '准备执行',
    worker_lost: 'Worker å¤šæ¬¡å¤±è”',
  };
  return values[stage] ?? stage;
}
function stopPolling() {
  if (pollTimer) clearTimeout(pollTimer);
  pollTimer = undefined;
  eventAbort?.abort();
  eventAbort = undefined;
}
async function loadTasks(silent = false) {
  if (!props.enabled) return;
  if (!silent) loading.value = true;
  try {
    const result = await listAgentTasks({ page_size: 50 });
    tasks.value = result.items ?? [];
  } catch (error) {
    if (!silent) message.error(agentImportErrorMessage(error));
  } finally {
    if (!silent) loading.value = false;
  }
}
function schedulePolling() {
  if (!visible.value || !props.enabled) return;
  pollTimer = setTimeout(async () => {
    await loadTasks(true);
    schedulePolling();
  }, 3000);
}
async function watchTask(taskId: string, since = 0) {
  eventAbort?.abort();
  const controller = new AbortController();
  eventAbort = controller;
  let cursor = since;
  let retryDelay = 1000;
  while (visible.value && !controller.signal.aborted) {
    try {
      cursor = await streamAgentTaskEvents(
        taskId,
        cursor,
        controller.signal,
        (event) => {
          const index = tasks.value.findIndex((task) => task.task_id === taskId);
          if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...event };
        },
      );
      retryDelay = 1000;
    } catch {
      if (controller.signal.aborted) return;
    }
    await loadTasks(true);
    const latest = tasks.value.find((task) => task.task_id === taskId);
    if (!latest || isTerminal(latest.status) || controller.signal.aborted) return;
    cursor = Math.max(cursor, latest.sequence);
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, retryDelay);
      controller.signal.addEventListener(
        'abort',
        () => {
          clearTimeout(timer);
          resolve();
        },
        { once: true },
      );
    });
    retryDelay = Math.min(retryDelay * 2, 30_000);
  }
}
async function open(taskId?: string) {
  stopPolling();
  visible.value = true;
  await loadTasks();
  schedulePolling();
  const target = taskId
    ? tasks.value.find((task) => task.task_id === taskId)
    : tasks.value.find((task) => !isTerminal(task.status));
  if (target && !isTerminal(target.status)) void watchTask(target.task_id, target.sequence);
}
function close() {
  visible.value = false;
  stopPolling();
}
async function cancel(task: AgentTaskApi.Task) {
  try {
    await cancelAgentTask(task.task_id);
    message.success('任务已取消');
    await loadTasks(true);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function continueTask(task: AgentTaskApi.Task) {
  close();
  emit('continue', task);
}
defineExpose({ open, activeCount });
onBeforeUnmount(stopPolling);
</script>
<template>
  <a-drawer
    :open="visible"
    title="后台任务中心"
    width="min(560px, 100vw)"
    :body-style="{ padding: '16px' }"
    @close="close"
  >
    <a-alert
      v-if="!enabled"
      type="info"
      show-icon
      message="持久任务服务尚未启用"
      description="配置 PostgreSQL、Redis å¹¶å¯ç”¨ MOM_TASK_BACKEND åŽï¼Œå¯åœ¨è¿™é‡ŒæŸ¥çœ‹è·¨é¡µé¢æ‰§è¡Œçš„æ–‡æ¡£ä»»åŠ¡ã€‚"
    />
    <a-spin v-else :spinning="loading">
      <a-empty v-if="!tasks.length" description="暂无后台任务" />
      <div v-else class="space-y-3">
        <article
          v-for="task in tasks"
          :key="task.task_id"
          class="rounded-lg border border-border bg-card p-4 transition-colors duration-200 hover:border-blue-300"
        >
          <div class="flex items-start justify-between gap-3">
            <div class="min-w-0">
              <div class="font-medium text-gray-900 dark:text-gray-100">
                {{ task.result?.filename || '企业文档分析' }}
              </div>
              <div class="mt-1 text-xs text-gray-500">
                {{ stageText(task.stage) }} Â· ç¬¬ {{ task.attempt }} æ¬¡æ‰§è¡Œ
              </div>
            </div>
            <a-tag :color="statusColor(task.status)" class="!mr-0 shrink-0">
              {{ statusText(task.status) }}
            </a-tag>
          </div>
          <div class="mt-3" aria-live="polite">
            <a-progress
              v-if="task.progress.mode === 'determinate'"
              :percent="task.progress.percent ?? 0"
              :status="task.status === 'failed' ? 'exception' : undefined"
            />
            <div
              v-else
              class="rounded-md border border-dashed border-border bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300"
              role="status"
            >
              å½“前阶段工作量待确定
            </div>
            <div class="mt-1 flex justify-between gap-3 text-xs text-gray-500">
              <span>{{ task.message }}</span>
              <span v-if="task.progress.total !== null" class="shrink-0">
                {{ task.progress.completed }} / {{ task.progress.total }}
                {{ task.progress.unit === 'row' ? '行' : '' }}
              </span>
            </div>
          </div>
          <a-alert
            v-if="task.error?.message"
            class="mt-3"
            type="error"
            :message="task.error.message"
          />
          <div
            v-if="task.result?.candidate_count !== undefined"
            class="mt-3 rounded-md bg-gray-50 px-3 py-2 text-sm dark:bg-gray-800"
          >
            å·²è¯†åˆ« {{ task.result.candidate_count }} æ¡å€™é€‰æ•°æ®
            <span v-if="task.result.warnings?.length" class="ml-2 text-orange-600">
              {{ task.result.warnings.length }} æ¡æç¤º
            </span>
          </div>
          <a-alert
            v-if="task.result?.review_summary?.requires_review"
            class="mt-2"
            type="warning"
            show-icon
            :message="`需要人工复核:${task.result.review_summary.conflict_groups} ç»„冲突,${task.result.review_summary.incomplete_keys} æ¡ç¼ºå°‘å½’å¹¶é”®`"
          />
          <div
            v-else-if="task.result?.review_summary?.exact_duplicates_merged"
            class="mt-2 text-xs text-gray-500"
          >
            å·²è‡ªåŠ¨åˆå¹¶
            {{ task.result.review_summary.exact_duplicates_merged }} æ¡å®Œå…¨é‡å¤è®°å½•
          </div>
          <div class="mt-3 flex flex-wrap justify-end gap-2">
            <a-button
              v-if="task.allowed_actions.includes('cancel')"
              danger
              @click="cancel(task)"
            >
              å–消任务
            </a-button>
            <a-button
              v-if="
                task.status === 'succeeded' &&
                task.result?.attachment_id &&
                task.result?.continuation_supported !== false
              "
              type="primary"
              @click="continueTask(task)"
            >
              è¿›å…¥è¡¥å½•确认
            </a-button>
          </div>
        </article>
      </div>
    </a-spin>
  </a-drawer>
</template>
src/views/agent/dataEntry/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1523 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { MenuRecordRaw } from '@vben/types';
import type { AgentConversationApi } from '#/api/agent/conversation';
import type { AgentTaskApi } from '#/api/agent/conversation';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import {
  agentImportErrorMessage,
  createDocumentTask,
  createAgentConversation,
  deleteAgentConversation,
  downloadAgentConversationAttachment,
  downloadAgentReport,
  getAgentCapabilities,
  getAgentModels,
  getAuthorizedAgentCapabilities,
  getAgentConversation,
  listAgentConversations,
  sendAgentAction,
  sendAgentMessage,
  streamAgentConversationEvents,
  uploadAgentAttachment,
} from '#/api/agent/conversation';
import TaskCenterDrawer from './components/TaskCenterDrawer.vue';
defineOptions({ name: 'AgentDataEntry' });
const accessStore = useAccessStore();
const MAX_BYTES = 8 * 1024 * 1024;
const ACCEPT = '.csv,.xlsx,.docx,.pdf';
const authorizationScope = ref('');
const models = ref<AgentConversationApi.BusinessModelInfo[]>([]);
const selectedModelId = ref<number>();
const conversations = ref<AgentConversationApi.ConversationSummary[]>([]);
const active = ref<AgentConversationApi.Conversation>();
const loading = ref(false);
const busy = ref(false);
const creating = ref(false);
const newOpen = ref(false);
const newScenarios = ref<string[]>([]);
const newText = ref('');
const newTimeRange = ref<string[]>([]);
const newRecordLimit = ref<number>();
const newIndustry = ref('');
const newSkipRestDays = ref(false);
const newRestDayDescription = ref('');
const newRegion = ref('');
type ConstraintKey =
  | 'industry'
  | 'instruction'
  | 'record_limit'
  | 'region'
  | 'rest_day_description'
  | 'scenario_paths'
  | 'skip_rest_days'
  | 'time_range';
const newPriorities = ref<Record<ConstraintKey, boolean>>({
  industry: false,
  instruction: false,
  record_limit: false,
  region: false,
  rest_day_description: false,
  scenario_paths: false,
  skip_rest_days: false,
  time_range: false,
});
const newFiles = ref<File[]>([]);
const newAttachmentIds = ref<number[]>([]);
const durableTasksEnabled = ref(false);
const taskCenter = ref<{ open: (taskId?: string) => Promise<void> }>();
const interruptFile = ref<File>();
const mappingDraft = ref<Record<string, string>>({});
const reviseText = ref('');
const syncMes = ref(true);
const eventState = ref<'connected' | 'connecting' | 'idle' | 'retrying'>('idle');
interface ExecutionProgress {
  candidateCount: number;
  issueCount: number;
  label: string;
  modelId?: number;
  percent: number;
  stage: string;
}
const executionProgress = ref<ExecutionProgress>();
let eventAbort: AbortController | undefined;
let eventGeneration = 0;
let eventRefreshTimer: ReturnType<typeof setTimeout> | undefined;
const attachmentPreview = ref<{
  filename: string;
  kind: 'image' | 'pdf';
  open: boolean;
  url: string;
}>({ filename: '', kind: 'image', open: false, url: '' });
const modelOptions = computed(() =>
  models.value.map((model) => ({
    label: `${model.name || model.model} Â· ${model.platform}`,
    value: model.id,
  })),
);
const interrupt = computed(() => active.value?.interrupts?.at(-1));
const card = computed(() => interrupt.value?.card);
const actions = computed(() => {
  const list = [...(card.value?.actions ?? [])];
  // å¡ç‰‡å¯èƒ½åœ¨æäº¤å¼€å…³å…³é—­æ—¶ç”Ÿæˆï¼Œä»¥æœåŠ¡ç«¯å½“å‰å¼€å…³ä¸ºå‡†è¡¥å›žç¡®è®¤åŠ¨ä½œ
  if (
    card.value?.kind === 'preview' &&
    active.value?.commit_enabled &&
    !list.includes('confirm')
  ) {
    list.push('confirm');
  }
  return list;
});
const isGatewayModule = computed(() => active.value?.module_id === 'mdm.item');
const previewValueLabels: Record<string, Record<string, string>> = {
  status: { '0': '启用', '1': '停用' },
  types: {
    '1': 'IQC(来料检验)',
    '2': 'IPQC(过程检验)',
    '3': 'OQC(出货检验)',
    '4': 'RQC(退料检验)',
  },
};
const previewColumns = computed(() => [
  { title: '行号', dataIndex: 'row_index', key: 'row_index', width: 60 },
  ...(active.value?.row_fields ?? []).map((field) => ({
    title: field.label,
    dataIndex: field.name,
    key: field.name,
    width: 140,
    customRender: ({ text }: { text: unknown }) =>
      previewValueLabels[field.name]?.[String(text ?? '')] ?? text,
  })),
]);
const previewRows = computed(() =>
  (active.value?.rows ?? []).map((row, index) => ({
    candidate_id: row.candidate_id,
    row_index: index + 1,
    ...row.values,
  })),
);
const eventStateText = computed(() => {
  if (active.value?.finished) return '已同步';
  return {
    connected: '实时更新',
    connecting: '正在连接',
    idle: '未连接',
    retrying: '断线重连',
  }[eventState.value];
});
const eventStateColor = computed(() => {
  if (active.value?.finished || eventState.value === 'connected') return 'green';
  if (eventState.value === 'retrying') return 'orange';
  return 'blue';
});
interface ScenarioOption {
  description: string;
  label: string;
  name: string;
  value: string;
}
function flattenMenuScenarios(
  menus: MenuRecordRaw[],
  parentNames: string[] = [],
): ScenarioOption[] {
  return menus.flatMap((menu) => {
    const names = [...parentNames, menu.name].filter(Boolean);
    const current = menu.show === false
      ? []
      : [
          {
            description: menu.path,
            label: names.join(' / '),
            name: menu.name,
            value: menu.path,
          },
        ];
    return [
      ...current,
      ...flattenMenuScenarios(menu.children ?? [], names),
    ];
  });
}
const scenarioOptions = computed(() => {
  const seen = new Set<string>();
  return flattenMenuScenarios(accessStore.accessMenus).filter((item) => {
    if (!item.value || seen.has(item.value)) return false;
    seen.add(item.value);
    return true;
  });
});
function togglePriority(key: ConstraintKey) {
  newPriorities.value[key] = !newPriorities.value[key];
}
function buildConstraints(): AgentConversationApi.ConversationConstraints {
  const scenarioByPath = new Map(
    scenarioOptions.value.map((item) => [item.value, item.label]),
  );
  return {
    industry: newIndustry.value.trim() || undefined,
    instruction: newText.value.trim() || undefined,
    priority_fields: Object.entries(newPriorities.value)
      .filter(([, enabled]) => enabled)
      .map(([key]) => key),
    record_limit: newRecordLimit.value,
    region: newRegion.value.trim() || undefined,
    rest_day_description: newRestDayDescription.value.trim() || undefined,
    scenario_paths: newScenarios.value.map((path) =>
      `${scenarioByPath.get(path) ?? path} (${path})`,
    ),
    skip_rest_days:
      newSkipRestDays.value || newPriorities.value.skip_rest_days
        ? newSkipRestDays.value
        : undefined,
    time_end: newTimeRange.value[1],
    time_start: newTimeRange.value[0],
  };
}
function hasNewConditions() {
  return Boolean(
    newScenarios.value.length ||
      newTimeRange.value.length ||
      newRecordLimit.value ||
      newIndustry.value.trim() ||
      newSkipRestDays.value ||
      newRestDayDescription.value.trim() ||
      newRegion.value.trim(),
  );
}
function resetNewConditions() {
  newScenarios.value = [];
  newText.value = '';
  newTimeRange.value = [];
  newRecordLimit.value = undefined;
  newIndustry.value = '';
  newSkipRestDays.value = false;
  newRestDayDescription.value = '';
  newRegion.value = '';
  for (const key of Object.keys(newPriorities.value) as ConstraintKey[]) {
    newPriorities.value[key] = false;
  }
}
watch(
  () => interrupt.value?.interrupt_id,
  () => {
    reviseText.value = '';
    mappingDraft.value = {};
    interruptFile.value = undefined;
    const value = card.value?.sync_mes;
    syncMes.value =
      typeof value === 'boolean'
        ? value
        : (active.value?.sync_mes ?? active.value?.mes_sync_default ?? true);
  },
);
function statusText(status?: string) {
  const map: Record<string, string> = {
    SUCCEEDED: '导入成功',
    PARTIAL_FAILED: '部分失败',
    FAILED: '失败',
    UNKNOWN: '结果未知',
    RUNNING: '提交中',
    attached: '已上传',
    blocked: '已阻断',
    cancelled: '已取消',
    classified: '已识别场景',
    commit_disabled: '未提交',
    confirmed: '已确认',
    created: '待上传文件',
    queued: '排队处理中',
    error: '出错',
    failed: '失败',
    mapping_applied: '映射已更新',
    needs_review: '待确认',
    parsed: '已解析',
    preview_ready: '待确认',
    revise: '待重新校验',
    stale: '已过期,需重检',
    validated: '待确认',
  };
  return map[status ?? ''] ?? status ?? '进行中';
}
function statusColor(status?: string) {
  if (status === 'SUCCEEDED' || status === 'confirmed') return 'green';
  if (
    status === 'PARTIAL_FAILED' ||
    status === 'revise' ||
    status === 'stale'
  ) {
    return 'orange';
  }
  if (
    status === 'FAILED' ||
    status === 'failed' ||
    status === 'error' ||
    status === 'blocked' ||
    status === 'cancelled'
  ) {
    return 'red';
  }
  return 'blue';
}
function checkFile(raw: File) {
  const name = raw.name.toLowerCase();
  if (!ACCEPT.split(',').some((ext) => name.endsWith(ext))) {
    message.error('仅支持 CSV、XLSX、DOCX æˆ– PDF æ–‡ä»¶');
    return false;
  }
  if (raw.size > MAX_BYTES) {
    message.error('文件不能超过 8 MiB');
    return false;
  }
  return true;
}
function beforeNewUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  if (!checkFile(raw)) return false;
  const duplicate = newFiles.value.some(
    (item) =>
      item.name === raw.name && item.size === raw.size && item.lastModified === raw.lastModified,
  );
  if (!duplicate) newFiles.value = [...newFiles.value, raw].slice(0, 20);
  newAttachmentIds.value = [];
  return false;
}
function clearNewFiles() {
  newFiles.value = [];
  newAttachmentIds.value = [];
}
function beforeInterruptUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  if (!checkFile(raw)) return false;
  interruptFile.value = raw;
  return false;
}
async function boot() {
  loading.value = true;
  try {
    const [capabilities, authorization, list, modelList] = await Promise.all([
      getAgentCapabilities(),
      getAuthorizedAgentCapabilities(),
      listAgentConversations({ page_size: 50 }),
      getAgentModels().catch(() => []),
    ]);
    authorizationScope.value = authorization.scope_summary;
    models.value = modelList;
    if (!selectedModelId.value) {
      selectedModelId.value = active.value?.model_id ?? modelList[0]?.id;
    }
    const visibleScenarioPaths = new Set(scenarioOptions.value.map((item) => item.value));
    newScenarios.value = newScenarios.value.filter((path) =>
      visibleScenarioPaths.has(path),
    );
    durableTasksEnabled.value = capabilities.durable_tasks?.enabled ?? false;
    conversations.value = list.items ?? [];
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    loading.value = false;
  }
}
async function ensureNewAttachments() {
  if (newAttachmentIds.value.length === newFiles.value.length) {
    return newAttachmentIds.value;
  }
  newAttachmentIds.value = [];
  for (const file of newFiles.value) {
    newAttachmentIds.value.push(await uploadAgentAttachment(file));
  }
  return newAttachmentIds.value;
}
async function reloadConversations() {
  try {
    const list = await listAgentConversations({ page_size: 50 });
    conversations.value = list.items ?? [];
  } catch {
    // åˆ—表刷新失败不阻塞主流程
  }
}
function abortEventStream() {
  eventGeneration += 1;
  eventAbort?.abort();
  eventAbort = undefined;
  if (eventRefreshTimer) clearTimeout(eventRefreshTimer);
  eventRefreshTimer = undefined;
  eventState.value = 'idle';
}
async function refreshActive(conversationId: string) {
  const snapshot = await getAgentConversation(conversationId);
  if (active.value?.conversation_id !== conversationId) return;
  active.value = snapshot;
  await reloadConversations();
  if (snapshot.finished) abortEventStream();
}
function queueEventRefresh(conversationId: string) {
  if (eventRefreshTimer) clearTimeout(eventRefreshTimer);
  eventRefreshTimer = setTimeout(() => {
    eventRefreshTimer = undefined;
    void refreshActive(conversationId).catch(() => {
      // SSE ä¼šç»§ç»­é‡è¿žï¼Œå¿«ç…§è¯»å–失败不重复弹窗打扰操作。
    });
  }, 120);
}
function handleConversationEvent(
  conversationId: string,
  event: AgentConversationApi.ConversationEvent,
) {
  if (active.value?.conversation_id !== conversationId) return;
  if (event.type === 'progress') {
    const payload = event.payload;
    executionProgress.value = {
      candidateCount: Number(payload.candidate_count ?? 0),
      issueCount: Number(payload.issue_count ?? 0),
      label: String(payload.label ?? '正在处理'),
      modelId:
        typeof payload.model_id === 'number' ? payload.model_id : undefined,
      percent: Math.min(100, Math.max(0, Number(payload.percent ?? 0))),
      stage: String(payload.stage ?? 'processing'),
    };
    return;
  }
  queueEventRefresh(conversationId);
}
function waitForRetry(delay: number, signal: AbortSignal) {
  return new Promise<void>((resolve) => {
    const timer = setTimeout(resolve, delay);
    signal.addEventListener(
      'abort',
      () => {
        clearTimeout(timer);
        resolve();
      },
      { once: true },
    );
  });
}
function startEventStream(conversationId: string) {
  abortEventStream();
  const generation = eventGeneration;
  const controller = new AbortController();
  eventAbort = controller;
  eventState.value = 'connecting';
  void (async () => {
    let cursor = 0;
    let retryDelay = 1000;
    while (!controller.signal.aborted && generation === eventGeneration) {
      try {
        cursor = await streamAgentConversationEvents(
          conversationId,
          cursor,
          controller.signal,
          (event) => handleConversationEvent(conversationId, event),
          () => {
            eventState.value = 'connected';
            retryDelay = 1000;
          },
        );
        if (!controller.signal.aborted) eventState.value = 'connecting';
      } catch {
        if (controller.signal.aborted) return;
        eventState.value = 'retrying';
        try {
          await refreshActive(conversationId);
        } catch {
          // è½®è¯¢å…œåº•失败后继续指数退避。
        }
        await waitForRetry(retryDelay, controller.signal);
        retryDelay = Math.min(retryDelay * 2, 30_000);
      }
    }
  })();
}
async function openConversation(conversationId: string) {
  if (busy.value) return;
  busy.value = true;
  try {
    const conversation = await getAgentConversation(conversationId);
    active.value = conversation;
    selectedModelId.value = conversation.model_id ?? models.value[0]?.id;
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
function changeModel(value: number) {
  selectedModelId.value = value;
}
async function createConversation() {
  if (!newFiles.value.length && !newText.value.trim() && !hasNewConditions()) {
    message.warning('请上传文件、填写补录说明或设置补录条件');
    return;
  }
  creating.value = true;
  try {
    if (newFiles.value.length > 1) {
      message.warning('多文件请使用后台分析,归并检查完成后再进入补录');
      return;
    }
    const [attachmentId] = await ensureNewAttachments();
    const description = newText.value.trim();
    const baseTitle =
      newFiles.value[0]?.name || description.slice(0, 60) || '新建补录';
    active.value = await createAgentConversation({
      attachment_id: attachmentId ? String(attachmentId) : undefined,
      constraints: buildConstraints(),
      model_id: selectedModelId.value,
      text: description || undefined,
      title: baseTitle,
    });
    newOpen.value = false;
    clearNewFiles();
    resetNewConditions();
    await reloadConversations();
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    creating.value = false;
  }
}
async function startBackgroundAnalysis() {
  if (!durableTasksEnabled.value) {
    message.info('持久任务服务尚未启用');
    return;
  }
  if (!newFiles.value.length) {
    message.warning('后台分析需要先选择数据文件');
    return;
  }
  creating.value = true;
  try {
    const attachmentIds = await ensureNewAttachments();
    if (!attachmentIds.length) return;
    const task = await createDocumentTask({
      attachment_ids: attachmentIds,
      constraints: buildConstraints(),
    });
    newOpen.value = false;
    resetNewConditions();
    message.success('后台分析任务已创建,可关闭页面后继续执行');
    await taskCenter.value?.open(task.task_id);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    creating.value = false;
  }
}
async function continueFromTask(task: AgentTaskApi.Task) {
  if (task.result?.continuation_supported === false) {
    message.info('多文件结果需先在证据明细中处理冲突,当前暂不进入单文件提交');
    return;
  }
  const attachmentId = task.result?.attachment_id;
  if (!attachmentId) {
    message.error('任务结果缺少附件引用,无法进入补录');
    return;
  }
  busy.value = true;
  try {
    active.value = await createAgentConversation({
      attachment_id: String(attachmentId),
      model_id: selectedModelId.value,
      module_id: task.result?.module_id || undefined,
      text: '后台文档分析已完成,请继续进行字段核验和补录确认。',
      title: task.result?.filename || '后台分析补录',
    });
    await reloadConversations();
    message.success('已进入补录确认流程');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
async function apply(result: AgentConversationApi.Conversation, tip?: string) {
  active.value = result;
  selectedModelId.value = result.model_id ?? selectedModelId.value;
  if (tip) message.success(tip);
  await reloadConversations();
}
async function act(
  action: string,
  payload: Record<string, unknown> = {},
  tip?: string,
) {
  const current = active.value;
  const pending = interrupt.value;
  if (!current || !pending) return;
  busy.value = true;
  try {
    const result = await sendAgentAction(current.conversation_id, {
      action,
      interrupt_id: pending.interrupt_id,
      model_id: selectedModelId.value,
      payload,
      revision: pending.revision,
    });
    await apply(result, tip);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
function chooseModule(moduleId: string) {
  return act('choose_scenario', { module_id: moduleId }, '已选择业务场景');
}
async function confirmMapping() {
  const missing = card.value?.missing_fields ?? [];
  const mapping: Record<string, string> = {};
  for (const field of missing) {
    const column = mappingDraft.value[field.name];
    if (!column) {
      message.warning(`请为「${field.label}」选择对应列`);
      return;
    }
    mapping[field.name] = column;
  }
  await act('confirm_mapping', { mapping }, '映射已提交,重新校验');
}
async function uploadForFileInterrupt() {
  const current = active.value;
  if (!current) return;
  if (!interruptFile.value) {
    message.warning('请先选择文件');
    return;
  }
  busy.value = true;
  try {
    const attachmentId = await uploadAgentAttachment(interruptFile.value);
    const result = await sendAgentMessage(current.conversation_id, {
      attachment_id: String(attachmentId),
      model_id: selectedModelId.value,
      text: '已上传要补录的文件。',
    });
    interruptFile.value = undefined;
    await apply(result);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
async function revise() {
  const text = reviseText.value.trim();
  const payload: Record<string, unknown> = {};
  if (text) payload.text = text;
  if (isGatewayModule.value) payload.sync_mes = syncMes.value;
  if (!text && !isGatewayModule.value) {
    message.warning('请输入修订内容,例如“第2行 åç§° æ”¹ä¸º XXX”');
    return;
  }
  await act('revise', payload, '已提交修订,重新校验');
  reviseText.value = '';
}
async function toggleSync(value: boolean) {
  syncMes.value = value;
  await act('revise', { sync_mes: value }, '已更新同步选项,重新校验');
}
function confirmImport() {
  return act('confirm', {}, '已确认,正在提交');
}
function cancelConversation() {
  return act('cancel', {}, '已取消本次补录');
}
async function downloadReport() {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentReport(current.conversation_id);
    const disposition = String(
      response.headers?.['content-disposition'] ?? '',
    );
    const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);
    downloadFileFromBlobPart({
      fileName: match?.[1] || `${current.title || '补录回执'}.xlsx`,
      source: response.data,
    });
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function decodeAttachmentFilename(
  headers: Record<string, string> | undefined,
  fallback: string,
) {
  const encoded = headers?.['x-agent-filename-b64'];
  if (!encoded) return fallback;
  try {
    const binary = atob(encoded);
    const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
    return new TextDecoder().decode(bytes);
  } catch {
    return fallback;
  }
}
function canPreview(filename?: string) {
  return /\.(pdf|png|jpe?g|gif|webp|bmp)$/i.test(filename || '');
}
async function previewAttachment(
  attachment: AgentConversationApi.ConversationAttachment,
) {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentConversationAttachment(
      current.conversation_id,
      attachment.attachment_id,
      'inline',
    );
    const filename = decodeAttachmentFilename(
      response.headers,
      attachment.filename || `附件-${attachment.attachment_id}`,
    );
    closeAttachmentPreview();
    attachmentPreview.value = {
      filename,
      kind: /\.pdf$/i.test(filename) ? 'pdf' : 'image',
      open: true,
      url: URL.createObjectURL(response.data),
    };
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function closeAttachmentPreview() {
  if (attachmentPreview.value.url) URL.revokeObjectURL(attachmentPreview.value.url);
  attachmentPreview.value = { filename: '', kind: 'image', open: false, url: '' };
}
async function downloadAttachment(
  attachment: AgentConversationApi.ConversationAttachment,
) {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentConversationAttachment(
      current.conversation_id,
      attachment.attachment_id,
      'download',
    );
    downloadFileFromBlobPart({
      fileName: decodeAttachmentFilename(
        response.headers,
        attachment.filename || `附件-${attachment.attachment_id}`,
      ),
      source: response.data,
    });
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
async function removeConversation(
  item: AgentConversationApi.ConversationSummary,
) {
  try {
    await deleteAgentConversation(item.conversation_id);
    if (active.value?.conversation_id === item.conversation_id) {
      abortEventStream();
      active.value = undefined;
    }
    await reloadConversations();
    message.success('会话及附件已删除');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
onMounted(boot);
onBeforeUnmount(() => {
  abortEventStream();
  closeAttachmentPreview();
});
watch(
  () => active.value?.conversation_id,
  (conversationId) => {
    executionProgress.value = undefined;
    if (conversationId && !active.value?.finished) startEventStream(conversationId);
    else abortEventStream();
  },
);
</script>
<template>
  <Page auto-content-height>
    <div class="flex h-full gap-3 p-3">
      <aside
        class="flex w-64 shrink-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
      >
        <div class="border-b border-border p-3">
          <a-button type="primary" block @click="newOpen = true">
            æ–°å»ºè¡¥å½•
          </a-button>
          <div class="mt-2 text-xs text-gray-500">
            ä¸Šä¼ æ–‡ä»¶åŽè‡ªåŠ¨è¯†åˆ«ä¸šåŠ¡åœºæ™¯ï¼Œç¡®è®¤å‰ä¼šå®Œæ•´æ ¡éªŒã€‚
          </div>
        </div>
        <div class="flex-1 overflow-y-auto p-2">
          <a-spin :spinning="loading">
            <a-empty
              v-if="!conversations.length"
              description="暂无补录会话"
            />
            <div
              v-for="item in conversations"
              :key="item.conversation_id"
              class="mb-1 flex w-full cursor-pointer items-start gap-1 rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
              :class="{
                'bg-blue-50 dark:bg-blue-900/30':
                  item.conversation_id === active?.conversation_id,
              }"
              role="button"
              tabindex="0"
              @click="openConversation(item.conversation_id)"
              @keydown.enter="openConversation(item.conversation_id)"
            >
              <div class="min-w-0 flex-1">
                <div class="truncate text-sm font-medium">
                  {{ item.title || '未命名补录' }}
                </div>
                <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
                  <a-tag :color="statusColor(item.status)" class="!mr-0">
                    {{ statusText(item.status) }}
                  </a-tag>
                  <span class="truncate">
                    {{ item.module_title || '待识别' }}
                  </span>
                </div>
              </div>
              <a-popconfirm
                cancel-text="取消"
                ok-text="删除"
                title="删除会话及已上传附件?"
                @confirm="removeConversation(item)"
              >
                <a-button
                  class="shrink-0"
                  danger
                  size="small"
                  type="text"
                  @click.stop
                >
                  åˆ é™¤
                </a-button>
              </a-popconfirm>
            </div>
          </a-spin>
        </div>
      </aside>
      <section
        class="flex min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card"
      >
        <header
          class="flex items-center justify-between border-b border-border px-4 py-3"
        >
          <div class="min-w-0">
            <div class="truncate text-base font-semibold">
              {{ active?.title || '模块数据补录' }}
            </div>
            <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
              <a-tag :color="statusColor(active?.status)" class="!mr-0">
                {{ statusText(active?.status) }}
              </a-tag>
              <span v-if="active?.module_title">{{ active.module_title }}</span>
              <span v-if="active?.total">共 {{ active.total }} è¡Œ</span>
              <span v-if="active?.data_scope_summary || authorizationScope">
                æ•°æ®èŒƒå›´ï¼š{{ active?.data_scope_summary || authorizationScope }}
              </span>
              <span
                v-if="modelOptions.length"
                class="flex items-center gap-1"
                title="切换后从下一次分析开始生效"
              >
                æ¨¡åž‹ï¼š
                <a-select
                  :disabled="busy"
                  :options="modelOptions"
                  :value="selectedModelId"
                  class="w-56"
                  size="small"
                  @change="changeModel"
                />
              </span>
              <a-tag
                v-if="active"
                :color="eventStateColor"
                class="!mr-0"
                aria-live="polite"
              >
                {{ eventStateText }}
              </a-tag>
            </div>
            <div
              v-if="active?.attachments?.length"
              class="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-500"
            >
              <span>附件:</span>
              <span
                v-for="attachment in active.attachments"
                :key="attachment.attachment_id"
                class="flex items-center gap-1 rounded border border-border px-2 py-0.5"
              >
                <span class="max-w-60 truncate">
                  {{ attachment.filename || `附件 ${attachment.attachment_id}` }}
                </span>
                <a-button
                  v-if="canPreview(attachment.filename)"
                  class="!h-5 !px-1"
                  size="small"
                  type="link"
                  @click="previewAttachment(attachment)"
                >
                  é¢„览
                </a-button>
                <a-button
                  class="!h-5 !px-1"
                  size="small"
                  type="link"
                  @click="downloadAttachment(attachment)"
                >
                  ä¸‹è½½
                </a-button>
              </span>
            </div>
          </div>
          <div class="flex shrink-0 items-center gap-2">
            <a-tooltip
              :title="
                durableTasksEnabled
                  ? '查看跨页面执行的文档任务'
                  : '持久任务服务尚未启用'
              "
            >
              <a-button @click="taskCenter?.open()">后台任务</a-button>
            </a-tooltip>
            <a-button
              v-if="active?.receipt?.command_id"
              :disabled="busy"
              @click="downloadReport"
            >
              ä¸‹è½½å›žæ‰§
            </a-button>
          </div>
        </header>
        <div
          v-if="executionProgress && active"
          class="border-b border-border bg-blue-50/70 px-4 py-2 dark:bg-blue-950/20"
          aria-live="polite"
        >
          <div class="mb-1 flex items-center justify-between gap-4 text-xs">
            <span class="font-medium text-blue-700 dark:text-blue-300">
              {{ executionProgress.label }}
            </span>
            <span class="text-gray-500">
              å·²å‘现 {{ executionProgress.candidateCount }} æ¡å€™é€‰
              <template v-if="executionProgress.issueCount">
                Â· {{ executionProgress.issueCount }} é¡¹å¾…核对
              </template>
            </span>
          </div>
          <a-progress
            :percent="executionProgress.percent"
            :status="executionProgress.stage === 'error' ? 'exception' : 'active'"
            size="small"
          />
        </div>
        <div class="flex-1 overflow-y-auto p-4">
          <a-empty
            v-if="!active"
            description="从左侧选择会话,或点击「新建补录」开始"
          />
          <div v-else class="space-y-3">
            <div
              v-for="item in active.messages"
              :key="item.message_id"
              class="flex"
              :class="item.role === 'user' ? 'justify-end' : 'justify-start'"
            >
              <div
                class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
                :class="
                  item.role === 'user'
                    ? 'bg-blue-500 text-white'
                    : 'bg-gray-100 dark:bg-gray-800'
                "
              >
                <div class="whitespace-pre-wrap">{{ item.text }}</div>
                <div
                  v-for="(msgCard, index) in item.cards || []"
                  :key="`${item.message_id}-${index}`"
                  class="mt-2 rounded-md border border-border bg-card p-2 text-gray-800 dark:text-gray-100"
                >
                  <template v-if="msgCard.kind === 'receipt'">
                    <div class="mb-1 font-semibold">
                      {{ msgCard.title || '导入回执' }}
                    </div>
                    <div class="text-xs text-gray-500">
                      æˆåŠŸ {{ msgCard.summary?.success ?? 0 }},跳过
                      {{ msgCard.summary?.skipped ?? 0 }},失败
                      {{ msgCard.summary?.failed ?? 0 }}
                    </div>
                    <a-table
                      class="mt-2"
                      :columns="[
                        { title: '编码', dataIndex: 'code', width: 160 },
                        { title: '名称', dataIndex: 'name' },
                        { title: '结果', dataIndex: 'status', width: 90 },
                      ]"
                      :data-source="msgCard.rows || []"
                      :pagination="false"
                      row-key="candidate_id"
                      size="small"
                    >
                      <template #bodyCell="{ column, record }">
                        <a-tag
                          v-if="column.dataIndex === 'status'"
                          :color="record.status === 'SUCCESS' ? 'green' : 'red'"
                        >
                          {{ record.status }}
                        </a-tag>
                      </template>
                    </a-table>
                    <a-button
                      class="mt-2"
                      size="small"
                      type="link"
                      @click="downloadReport"
                    >
                      ä¸‹è½½æŠ¥è¡¨
                    </a-button>
                  </template>
                </div>
              </div>
            </div>
          </div>
        </div>
        <footer v-if="active && interrupt" class="border-t border-border p-3">
          <a-spin :spinning="busy">
            <!-- åœºæ™¯é€‰æ‹© -->
            <div v-if="interrupt.kind === 'scenario'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <div class="flex flex-wrap gap-2">
                <a-button
                  v-for="candidate in card?.candidates || []"
                  :key="candidate.module_id"
                  :disabled="busy"
                  @click="chooseModule(candidate.module_id)"
                >
                  {{ candidate.title }}
                  <a-tag class="ml-1" color="blue">
                    {{ candidate.capability }}
                  </a-tag>
                </a-button>
                <a-button
                  v-if="actions.includes('cancel')"
                  danger
                  :disabled="busy"
                  @click="cancelConversation"
                >
                  å–消
                </a-button>
              </div>
            </div>
            <!-- å­—段映射 -->
            <div v-else-if="interrupt.kind === 'mapping'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <div class="space-y-2">
                <div
                  v-for="field in card?.missing_fields || []"
                  :key="field.name"
                  class="flex items-center gap-2"
                >
                  <span class="w-28 text-sm">{{ field.label }}</span>
                  <a-select
                    v-model:value="mappingDraft[field.name]"
                    class="min-w-56"
                    placeholder="选择文件中对应的列"
                    :options="
                      (card?.columns || []).map((column) => ({
                        label: column,
                        value: column,
                      }))
                    "
                  />
                </div>
              </div>
              <div class="mt-3 flex gap-2">
                <a-button
                  type="primary"
                  :loading="busy"
                  @click="confirmMapping"
                >
                  ç¡®è®¤æ˜ å°„
                </a-button>
                <a-button danger :disabled="busy" @click="cancelConversation">
                  å–消
                </a-button>
              </div>
            </div>
            <!-- è¡¥ä¼ æ–‡ä»¶ -->
            <div v-else-if="interrupt.kind === 'file'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <a-upload
                accept=".csv,.xlsx,.docx,.pdf"
                :before-upload="beforeInterruptUpload"
                :max-count="1"
                :show-upload-list="false"
              >
                <a-button>{{ interruptFile ? '重新选择' : '选择文件' }}</a-button>
              </a-upload>
              <span v-if="interruptFile" class="ml-2 text-xs text-gray-500">
                {{ interruptFile.name }}
              </span>
              <div class="mt-3">
                <a-button
                  type="primary"
                  :disabled="!interruptFile"
                  :loading="busy"
                  @click="uploadForFileInterrupt"
                >
                  ä¸Šä¼ å¹¶ç»§ç»­
                </a-button>
              </div>
            </div>
            <!-- æ ¡éªŒ / é¢„览确认 -->
            <div v-else-if="['preview', 'validation'].includes(interrupt.kind)">
              <div class="mb-2 flex items-center justify-between">
                <span class="font-medium">{{ card?.title }}</span>
                <span class="text-xs text-gray-500">
                  {{ card?.notice }}
                </span>
              </div>
              <a-alert
                v-if="card?.issues?.length"
                class="mb-2"
                type="error"
                show-icon
              >
                <template #message>
                  é˜»æ–­é¡¹ {{ card.issues.length }} æ¡ï¼Œéœ€ä¿®è®¢åŽé‡æ–°æ ¡éªŒ
                </template>
                <ul class="mt-1 list-decimal pl-4 text-xs">
                  <li v-for="(issue, index) in card.issues" :key="index">
                    {{ issue.message }}
                    <span v-if="issue.code" class="text-gray-400">
                      ï¼ˆ{{ issue.code }})
                    </span>
                  </li>
                </ul>
              </a-alert>
              <a-alert
                v-if="card?.warnings?.length"
                class="mb-2"
                type="warning"
                show-icon
              >
                <template #message>
                  æç¤º {{ card.warnings.length }} æ¡ï¼Œä¸é˜»æ–­æäº¤
                </template>
                <ul class="mt-1 list-decimal pl-4 text-xs">
                  <li v-for="(item, index) in card.warnings" :key="index">
                    {{ item.message }}
                  </li>
                </ul>
              </a-alert>
              <a-alert
                v-if="card?.repair_guidance?.length"
                class="mb-2"
                type="info"
                show-icon
              >
                <template #message>业务大模型核对建议</template>
                <ul class="mt-1 list-disc pl-4 text-xs">
                  <li v-for="(item, index) in card.repair_guidance" :key="index">
                    <span v-if="item.field" class="font-medium">{{ item.field }}:</span>
                    {{ item.guidance }}
                  </li>
                </ul>
              </a-alert>
              <div
                v-if="card?.summary"
                class="mb-2 flex flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-300"
              >
                <span>共 {{ card.summary.total ?? 0 }} è¡Œ</span>
                <span class="text-green-600">
                  æ–°å»º {{ card.summary.create ?? 0 }}
                </span>
                <span class="text-orange-500">
                  è·³è¿‡ {{ card.summary.skip ?? 0 }}
                </span>
                <span class="text-red-500">
                  é˜»æ–­ {{ card.summary.blocked ?? 0 }}
                </span>
              </div>
              <div v-if="card?.side_effects?.length" class="mb-2 text-xs text-gray-500">
                é¢„期影响:{{ card.side_effects.join(';') }}
              </div>
              <div
                v-if="isGatewayModule"
                class="mb-2 flex items-center gap-2 text-sm"
              >
                <span>同步到 MES</span>
                <a-switch
                  :checked="syncMes"
                  :disabled="busy"
                  @change="toggleSync"
                />
                <span class="text-xs text-gray-500">
                  å…³é—­åŽä»…创建 ERP ç‰©æ–™
                </span>
              </div>
              <a-table
                v-if="previewRows.length"
                class="mb-2"
                :columns="previewColumns"
                :data-source="previewRows"
                :pagination="false"
                :scroll="{ x: 'max-content', y: 320 }"
                row-key="candidate_id"
                size="small"
                bordered
              />
              <div class="flex gap-2">
                <a-input
                  v-model:value="reviseText"
                  class="flex-1"
                  placeholder="如需修改,输入修订说明,例如:第2行 åç§° æ”¹ä¸º XXX"
                  :disabled="busy"
                  @press-enter="revise"
                />
                <a-button :disabled="busy" @click="revise">修订并重检</a-button>
                <a-button
                  v-if="actions.includes('confirm')"
                  type="primary"
                  :disabled="busy || card?.can_commit === false"
                  :loading="busy"
                  @click="confirmImport"
                >
                  ç¡®è®¤å¯¼å…¥
                </a-button>
                <a-button
                  v-if="actions.includes('cancel')"
                  danger
                  :disabled="busy"
                  @click="cancelConversation"
                >
                  å–消
                </a-button>
              </div>
            </div>
            <a-alert
              v-else
              type="info"
              show-icon
              :message="card?.title || '当前步骤需要处理'"
              :description="card?.body"
            />
          </a-spin>
        </footer>
      </section>
      <TaskCenterDrawer
        ref="taskCenter"
        :enabled="durableTasksEnabled"
        @continue="continueFromTask"
      />
      <a-modal
        v-model:open="attachmentPreview.open"
        :footer="null"
        :title="attachmentPreview.filename"
        width="80%"
        @cancel="closeAttachmentPreview"
      >
        <iframe
          v-if="attachmentPreview.kind === 'pdf'"
          :src="attachmentPreview.url"
          class="h-[70vh] w-full border-0"
          title="附件预览"
        />
        <img
          v-else
          :src="attachmentPreview.url"
          alt="附件预览"
          class="max-h-[70vh] w-full object-contain"
        />
      </a-modal>
      <a-modal
        v-model:open="newOpen"
        title="新建补录"
        :confirm-loading="creating"
        :width="760"
      >
        <div class="space-y-3">
          <div>
            <div class="mb-1 flex items-center justify-between text-sm">
              <span>业务场景(可多选)</span>
              <a-button
                size="small"
                :type="newPriorities.scenario_paths ? 'primary' : 'default'"
                @click="togglePriority('scenario_paths')"
              >优先</a-button>
            </div>
            <a-select
              v-model:value="newScenarios"
              class="w-full"
              mode="multiple"
              :max-tag-count="3"
              :options="scenarioOptions"
              option-filter-prop="label"
              placeholder="请选择当前补录涉及的菜单模块"
              show-search
            >
              <template #option="option">
                <div class="py-1">
                  <div class="font-medium">{{ option.label }}</div>
                  <div v-if="option.description" class="mt-0.5 text-xs text-gray-500">
                    {{ option.description }}
                  </div>
                </div>
              </template>
            </a-select>
            <div class="mt-1 text-xs text-gray-500">
              å¼€å¯â€œä¼˜å…ˆâ€åŽæŒ‰ç¡¬çº¦æŸå¤„理;最终模块仍必须通过能力与数据权限校验。
            </div>
          </div>
          <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>时间范围</span>
                <a-button size="small" :type="newPriorities.time_range ? 'primary' : 'default'" @click="togglePriority('time_range')">优先</a-button>
              </div>
              <a-range-picker v-model:value="newTimeRange" class="w-full" value-format="YYYY-MM-DD" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>数据条数</span>
                <a-button size="small" :type="newPriorities.record_limit ? 'primary' : 'default'" @click="togglePriority('record_limit')">优先</a-button>
              </div>
              <a-input-number v-model:value="newRecordLimit" class="w-full" :min="1" :max="100000" placeholder="例如:500" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>行业</span>
                <a-button size="small" :type="newPriorities.industry ? 'primary' : 'default'" @click="togglePriority('industry')">优先</a-button>
              </div>
              <a-input v-model:value="newIndustry" maxlength="80" placeholder="例如:汽车零部件" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>区域</span>
                <a-button size="small" :type="newPriorities.region ? 'primary' : 'default'" @click="togglePriority('region')">优先</a-button>
              </div>
              <a-input v-model:value="newRegion" maxlength="120" placeholder="例如:华东 / æ±Ÿè‹å—通" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>跳过休息日</span>
                <a-button size="small" :type="newPriorities.skip_rest_days ? 'primary' : 'default'" @click="togglePriority('skip_rest_days')">优先</a-button>
              </div>
              <div class="flex h-8 items-center gap-2">
                <a-switch v-model:checked="newSkipRestDays" />
                <span class="text-xs text-gray-500">{{ newSkipRestDays ? '跳过' : '不跳过' }}</span>
              </div>
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>休息日描述</span>
                <a-button size="small" :type="newPriorities.rest_day_description ? 'primary' : 'default'" @click="togglePriority('rest_day_description')">优先</a-button>
              </div>
              <a-input v-model:value="newRestDayDescription" maxlength="300" placeholder="例如:周日及法定节假日" />
            </div>
          </div>
          <div>
            <div class="mb-1 flex items-center justify-between text-sm">
              <span>补录说明(可选)</span>
              <a-button size="small" :type="newPriorities.instruction ? 'primary' : 'default'" @click="togglePriority('instruction')">优先</a-button>
            </div>
            <a-textarea
              v-model:value="newText"
              :rows="2"
              placeholder="例如:导入一批供应商档案"
            />
          </div>
          <div>
            <div class="mb-1 text-sm">数据文件</div>
            <a-upload
              accept=".csv,.xlsx,.docx,.pdf"
              :before-upload="beforeNewUpload"
              :max-count="20"
              multiple
              :show-upload-list="false"
            >
              <a-button>选择文件</a-button>
            </a-upload>
            <span v-if="newFiles.length" class="ml-2 text-xs text-gray-500">
              å·²é€‰æ‹© {{ newFiles.length }} ä¸ªæ–‡ä»¶
            </span>
            <a-button v-if="newFiles.length" class="ml-2" size="small" type="link" @click="clearNewFiles">
              æ¸…空
            </a-button>
            <div v-if="newFiles.length" class="mt-2 max-h-20 overflow-auto text-xs text-gray-500">
              <div v-for="file in newFiles" :key="`${file.name}:${file.size}:${file.lastModified}`">
                {{ file.name }}
              </div>
            </div>
            <div class="mt-1 text-xs text-gray-500">
              æ”¯æŒä¸€æ¬¡é€‰æ‹©æœ€å¤š 20 ä¸ª CSV、XLSX、DOCX ä¸Žå¯å¤åˆ¶æ–‡æœ¬çš„ PDF,单个文件不超过 8
              MiB。多文件会在后台做跨文件重复和冲突检查;扫描件暂不支持。
            </div>
          </div>
        </div>
        <template #footer>
          <div class="flex flex-wrap justify-end gap-2">
            <a-button :disabled="creating" @click="newOpen = false">
              å–消
            </a-button>
            <a-tooltip
              :title="
                !durableTasksEnabled
                  ? '需先启用 PostgreSQL ä¸Ž Redis æŒä¹…任务服务'
                  : '任务会根据文件内容自动识别,关闭页面后仍可从任务中心恢复'
              "
            >
              <a-button
                :disabled="!durableTasksEnabled || !newFiles.length"
                :loading="creating"
                @click="startBackgroundAnalysis"
              >
                åŽå°åˆ†æž
              </a-button>
            </a-tooltip>
            <a-button
              type="primary"
              :loading="creating"
              @click="createConversation"
            >
              ç›´æŽ¥å¼€å§‹è¡¥å½•
            </a-button>
          </div>
        </template>
      </a-modal>
    </div>
  </Page>
</template>
src/views/ai/model/model/data.ts
@@ -1,7 +1,9 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { AiModelCapabilityEnum, AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
@@ -41,6 +43,26 @@
        };
      },
      rules: 'required',
    },
    {
      fieldName: 'capability',
      label: '模型能力',
      component: 'Select',
      componentProps: (values) => {
        return {
          placeholder: '请选择模型能力',
          // åªæœ‰å¯¹è¯ç±»åž‹åŒºåˆ†æ–‡æœ¬/视觉,其余类型统一按文本能力处理
          disabled: !!values.id || values.type !== AiModelTypeEnum.CHAT,
          options: getDictOptions(DICT_TYPE.AI_MODEL_CAPABILITY, 'string'),
          allowClear: true,
        };
      },
      help: '同一类型同一能力只能启用一个模型;视觉能力用于图片识别',
      dependencies: {
        triggerFields: ['type'],
        show: (values) => values.type === AiModelTypeEnum.CHAT,
      },
      rules: z.string().default(AiModelCapabilityEnum.TEXT),
    },
    {
      fieldName: 'keyId',
@@ -162,11 +184,31 @@
      },
    },
    {
      fieldName: 'type',
      label: '模型类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择模型类型',
        options: getDictOptions(DICT_TYPE.AI_MODEL_TYPE, 'number'),
        allowClear: true,
      },
    },
    {
      fieldName: 'model',
      label: '模型标识',
      component: 'Input',
      componentProps: {
        placeholder: '请输入模型标识',
        allowClear: true,
      },
    },
    {
      fieldName: 'capability',
      label: '模型能力',
      component: 'Select',
      componentProps: {
        placeholder: '请选择模型能力',
        options: getDictOptions(DICT_TYPE.AI_MODEL_CAPABILITY, 'string'),
        allowClear: true,
      },
    },
@@ -183,23 +225,34 @@
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
export function useGridColumns(
  onStatusChange?: (newStatus: number, row: AiModelModelApi.Model) => Promise<boolean | undefined>,
): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'platform',
      title: '所属平台',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_PLATFORM },
      },
      minWidth: 100,
    },
    {
      field: 'type',
      title: '模型类型',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_MODEL_TYPE },
      },
      minWidth: 100,
    },
    {
      field: 'capability',
      title: '模型能力',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_MODEL_CAPABILITY },
      },
      minWidth: 100,
    },
    {
      field: 'platform',
      title: '所属平台',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_PLATFORM },
      },
      minWidth: 100,
    },
@@ -225,12 +278,17 @@
    },
    {
      field: 'status',
      title: '状态',
      title: '启用',
      minWidth: 100,
      align: 'center',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
        attrs: { beforeChange: onStatusChange },
        name: 'CellSwitch',
        props: {
          checkedValue: CommonStatusEnum.ENABLE,
          unCheckedValue: CommonStatusEnum.DISABLE,
        },
      },
      minWidth: 80,
    },
    {
      field: 'temperature',
src/views/ai/model/model/index.vue
@@ -2,12 +2,15 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { Page, useVbenModal } from '@vben/common-ui';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteModel, getModelPage } from '#/api/ai/model/model';
import { deleteModel, getModelPage, updateModelStatus } from '#/api/ai/model/model';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
@@ -17,6 +20,9 @@
  connectedComponent: Form,
  destroyOnClose: true,
});
/** å½“前页数据,spanMethod ä¾èµ–它计算类型列合并行数 */
const currentRows = ref<AiModelModelApi.Model[]>([]);
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -48,22 +54,71 @@
  }
}
/** æ›´æ–°æ¨¡åž‹å¯ç”¨çŠ¶æ€ï¼šå¯ç”¨ä¼šè‡ªåŠ¨å…³é—­åŒç±»åž‹åŒèƒ½åŠ›çš„å…¶ä»–æ¨¡åž‹ */
async function handleStatusChange(
  newStatus: number,
  row: AiModelModelApi.Model,
): Promise<boolean | undefined> {
  try {
    await confirm(
      newStatus === CommonStatusEnum.ENABLE
        ? `启用「${row.name}」后,同类型同能力下已启用的其他模型会自动关闭,确定继续吗?`
        : `你要停用「${row.name}」吗?停用后该类型该能力将没有可用模型。`,
    );
  } catch {
    return false;
  }
  await updateModelStatus(row.id!, newStatus);
  message.success($t('ui.actionMessage.operationSuccess'));
  // åŒç»„其他模型的启用状态已被后端改写,必须整页刷新
  handleRefresh();
  return true;
}
/** æ¨¡åž‹ç±»åž‹åˆ—按类型纵向合并,形成分类展示 */
function handleSpanMethod({
  column,
  rowIndex,
}: {
  column: { field?: string; property?: string };
  rowIndex: number;
}) {
  const field = column.field || column.property;
  if (field !== 'type') {
    return { colspan: 1, rowspan: 1 };
  }
  const row = currentRows.value[rowIndex];
  if (rowIndex > 0 && currentRows.value[rowIndex - 1]?.type === row?.type) {
    return { colspan: 0, rowspan: 0 };
  }
  let rowspan = 1;
  for (let index = rowIndex + 1; index < currentRows.value.length; index += 1) {
    if (currentRows.value[index]?.type !== row?.type) {
      break;
    }
    rowspan += 1;
  }
  return { colspan: 1, rowspan };
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    columns: useGridColumns(handleStatusChange),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getModelPage({
          const data = await getModelPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
          currentRows.value = data.list || [];
          return data;
        },
      },
    },
@@ -71,6 +126,7 @@
      keyField: 'id',
      isHover: true,
    },
    spanMethod: handleSpanMethod,
    toolbarConfig: {
      refresh: true,
      search: true,
src/views/basicData/mdm/data.ts
@@ -551,52 +551,46 @@
      title: "单位",
      minWidth: 80,
    },
    {
      field: "unitMeasureName2",
      title: "辅单位1",
      minWidth: 80,
    },
    {
      field: "unitMeasureRate1",
      title: "辅单位1换算比率",
      minWidth: 130,
    },
    {
      field: "unitMeasureName3",
      title: "辅单位2",
      minWidth: 80,
    },
    {
      field: "unitMeasureRate2",
      title: "辅单位2换算比率",
      minWidth: 130,
    },
    // è¾…单位1/辅单位1换算比率/辅单位2/辅单位2换算比率:物料列表不再展示(表单里仍可维护),需要恢复就取消注释
    // {
    //   field: "unitMeasureName2",
    //   title: "辅单位1",
    //   minWidth: 80,
    // },
    // {
    //   field: "unitMeasureRate1",
    //   title: "辅单位1换算比率",
    //   minWidth: 130,
    // },
    // {
    //   field: "unitMeasureName3",
    //   title: "辅单位2",
    //   minWidth: 80,
    // },
    // {
    //   field: "unitMeasureRate2",
    //   title: "辅单位2换算比率",
    //   minWidth: 130,
    // },
    {
      field: "warehouseName",
      title: "默认仓库",
      minWidth: 100,
    },
    {
      field: "purchasePrice",
      title: "采购价",
      minWidth: 100,
      formatter: ({ cellValue }) => {
        return cellValue != null ? `Â¥${Number(cellValue).toFixed(2)}` : "";
      },
    },
    {
      field: "salesPrice",
      title: "销售价",
      minWidth: 100,
      formatter: ({ cellValue }) => {
        return cellValue != null ? `Â¥${Number(cellValue).toFixed(2)}` : "";
      },
    },
    // é”€å”®ä»·ï¼šç‰©æ–™åˆ—表不再展示,需要恢复就取消注释
    // {
    //   field: "salesPrice",
    //   title: "销售价",
    //   minWidth: 100,
    //   formatter: ({ cellValue }) => {
    //     return cellValue != null ? `Â¥${Number(cellValue).toFixed(2)}` : "";
    //   },
    // },
    {
      field: "createTime",
      title: "创建时间",
      minWidth: 180,
      formatter: "formatDateTime",
      formatter: 'formatDate',
    },
    {
      field: "isBatchManaged",
src/views/basicData/mdm/index.vue
@@ -5,6 +5,8 @@
import { computed, ref } from 'vue';
import { isAiEnable } from '@vben/hooks';
import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart, isEmpty } from '#/packages/utils/src';
@@ -23,6 +25,7 @@
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AgentImport from './modules/agent-import.vue';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
@@ -35,6 +38,11 @@
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
const [AgentImportModal, agentImportModalApi] = useVbenModal({
  connectedComponent: AgentImport,
  destroyOnClose: true,
});
@@ -96,6 +104,56 @@
function handleImport() {
  importModalApi.open();
}
/** æ™ºèƒ½ä½“导入物料 */
function handleAgentImport() {
  agentImportModalApi.open();
}
/** å·¥å…·æ æŒ‰é’®ï¼šæ™ºèƒ½ä½“导入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['物料']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['mdm:item:create'],
    onClick: handleCreate,
  },
  {
    label: $t('ui.actionTitle.import', ['物料']),
    type: 'primary' as const,
    icon: ACTION_ICON.UPLOAD,
    auth: ['mdm:item:import'],
    onClick: handleImport,
  },
  ...(isAiEnable()
    ? [
        {
          label: '智能体导入',
          type: 'primary' as const,
          icon: ACTION_ICON.UPLOAD,
          auth: ['mdm:item:import'],
          onClick: handleAgentImport,
        },
      ]
    : []),
  {
    label: $t('ui.actionTitle.export'),
    type: 'primary' as const,
    icon: ACTION_ICON.DOWNLOAD,
    auth: ['mdm:item:export'],
    onClick: handleExport,
  },
  {
    label: $t('ui.actionTitle.deleteBatch'),
    type: 'primary' as const,
    danger: true,
    icon: ACTION_ICON.DELETE,
    auth: ['mdm:item:delete'],
    disabled: isEmpty(checkedIds),
    onClick: handleDeleteBatch,
  },
]);
/** ç¼–辑物料 */
function handleEdit(row: MdmItemApi.Item) {
@@ -223,42 +281,10 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <AgentImportModal @success="handleRefresh" />
    <Grid table-title="物料列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['物料']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mdm:item:create'],
              onClick: handleCreate,
            },
            {
              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',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mdm:item:delete'],
              disabled: isEmpty(checkedIds),
              onClick: handleDeleteBatch,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #categoryName="{ row }">
        <Tag v-if="getCategoryColor(row.categoryId)" :color="getCategoryColor(row.categoryId)">{{ getCategoryName(row.categoryId) }}</Tag>
src/views/basicData/mdm/modules/agent-import.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,610 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { MdmAgentImportApi } from '#/api/mdm/item/agent';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
  Alert,
  Button,
  Input,
  message,
  Steps,
  Table,
  Tag,
  Upload,
} from 'ant-design-vue';
import {
  agentImportErrorMessage,
  bindImportAttachment,
  createImportSession,
  getMaterialImportReceipt,
  reviewMaterialImport,
  startMaterialImport,
} from '#/api/mdm/item/agent';
import { uploadFile } from '#/api/system/storage';
defineOptions({ name: 'MdmAgentImport' });
const emit = defineEmits<{ success: [] }>();
const MAX_BYTES = 8 * 1024 * 1024;
const FIELD_KEYS = [
  'code',
  'name',
  'specification',
  'categoryCode',
  'unitMeasureCode',
] as const;
type FieldKey = (typeof FIELD_KEYS)[number];
interface EditableRow {
  candidate_id: string;
  row_no?: number;
  code: string;
  name: string;
  specification: string;
  categoryCode: string;
  unitMeasureCode: string;
  original: Record<string, string>;
  preflight?: MdmAgentImportApi.PreflightRow;
  issues: string[];
}
interface RowMessage {
  type: 'error' | 'warning';
  text: string;
}
const step = ref(1);
const file = ref<File>();
const fileName = ref('');
const processing = ref(false);
const taskId = ref('');
const state = ref<MdmAgentImportApi.ImportState>();
const receipt = ref<MdmAgentImportApi.Receipt>();
const rows = ref<EditableRow[]>([]);
const lastError = ref('');
let sessionId: number | undefined;
let attachmentId: number | undefined;
const preflight = computed(() => state.value?.preflight);
const issues = computed(() => state.value?.issues ?? []);
const canCommit = computed(
  () => !!preflight.value?.can_commit && issues.value.length === 0,
);
const hasChanges = computed(() => corrections.value.length > 0);
const corrections = computed<MdmAgentImportApi.Correction[]>(() => {
  const result: MdmAgentImportApi.Correction[] = [];
  for (const row of rows.value) {
    for (const field of FIELD_KEYS) {
      const before = (row.original[field] ?? '').trim();
      const after = (row[field] ?? '').trim();
      if (before !== after) {
        result.push({
          candidate_id: row.candidate_id,
          field,
          value: after,
          reason: '人工修订',
        });
      }
    }
  }
  return result;
});
const ACTION_LABEL: Record<MdmAgentImportApi.RowAction, string> = {
  BLOCKED: '阻断',
  CREATE: '新建',
  SKIP: '已存在',
};
const ACTION_COLOR: Record<MdmAgentImportApi.RowAction, string> = {
  BLOCKED: 'red',
  CREATE: 'green',
  SKIP: 'orange',
};
const STATUS_LABEL: Record<MdmAgentImportApi.RowStatus, string> = {
  FAILED: '失败',
  SKIPPED: '已跳过',
  SUCCESS: '成功',
  UNKNOWN: '未知',
};
const STATUS_COLOR: Record<MdmAgentImportApi.RowStatus, string> = {
  FAILED: 'red',
  SKIPPED: 'orange',
  SUCCESS: 'green',
  UNKNOWN: 'default',
};
const reviewColumns = [
  { dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
  { dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
  { dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
  { dataIndex: 'specification', key: 'specification', title: '规格型号', width: 150 },
  { dataIndex: 'categoryCode', key: 'categoryCode', title: '分类编码', width: 140 },
  {
    dataIndex: 'unitMeasureCode',
    key: 'unitMeasureCode',
    title: '计量单位',
    width: 130,
  },
  { key: 'action', title: '预检动作', width: 100 },
  { key: 'message', title: '校验信息', width: 260 },
];
const receiptColumns = [
  { dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
  { dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
  { dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
  { key: 'action', title: '动作', width: 90 },
  { key: 'status', title: '结果', width: 90 },
  { dataIndex: 'target_item_id', key: 'target_item_id', title: '物料 ID', width: 100 },
  { key: 'error', title: '失败原因', width: 220 },
];
function isEditableColumn(key: unknown): key is FieldKey {
  return typeof key === 'string' && (FIELD_KEYS as readonly string[]).includes(key);
}
function rowMessages(row: EditableRow): RowMessage[] {
  return [
    ...(row.preflight?.blockers ?? []).map((item) => ({
      type: 'error' as const,
      text: item.message,
    })),
    ...(row.preflight?.warnings ?? []).map((item) => ({
      type: 'warning' as const,
      text: item.message,
    })),
    ...row.issues.map((text) => ({ type: 'warning' as const, text })),
  ];
}
function buildRows(data: MdmAgentImportApi.ImportState): EditableRow[] {
  const plannedRows = data.preflight?.rows ?? [];
  return (data.candidates ?? [])
    .map((candidate) => {
      const planned = plannedRows.find(
        (item) => item.candidate_id === candidate.candidate_id,
      );
      const values = candidate.values ?? {};
      return {
        candidate_id: candidate.candidate_id,
        row_no: planned?.row_no ?? candidate.evidence?.code?.row,
        code: values.code ?? '',
        name: values.name ?? '',
        specification: values.specification ?? '',
        categoryCode: values.categoryCode ?? '',
        unitMeasureCode: values.unitMeasureCode ?? '',
        original: { ...values },
        preflight: planned,
        issues: candidate.issues ?? [],
      };
    })
    .sort((a, b) => (a.row_no ?? 0) - (b.row_no ?? 0));
}
function applyState(payload: MdmAgentImportApi.PublicState) {
  state.value = payload.state;
  taskId.value = payload.state.task_id;
  lastError.value =
    payload.state.last_error || payload.interrupts?.[0]?.last_error || '';
  receipt.value = payload.state.receipt;
  rows.value = buildRows(payload.state);
  step.value = receipt.value ? 3 : 2;
  syncFooter();
}
function syncFooter() {
  void modalApi.setState({
    cancelText: step.value === 2 ? '取消任务' : '取消',
    confirmText:
      step.value === 1 ? '开始解析' : step.value === 2 ? '确认提交' : '完成',
    showCancelButton: step.value !== 3,
  });
}
function reset() {
  step.value = 1;
  file.value = undefined;
  fileName.value = '';
  processing.value = false;
  taskId.value = '';
  state.value = undefined;
  receipt.value = undefined;
  rows.value = [];
  lastError.value = '';
  sessionId = undefined;
  attachmentId = undefined;
}
function beforeUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  const name = raw.name.toLowerCase();
  if (!name.endsWith('.csv') && !name.endsWith('.xlsx')) {
    message.error('仅支持 CSV æˆ– XLSX æ–‡ä»¶');
    return false;
  }
  if (raw.size > MAX_BYTES) {
    message.error('文件不能超过 8 MiB');
    return false;
  }
  file.value = raw;
  fileName.value = raw.name;
  return false;
}
async function startImport() {
  if (!file.value) {
    message.warning('请先选择 CSV/XLSX æ–‡ä»¶');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    sessionId = await createImportSession();
    const blobs = await uploadFile([file.value]);
    const blobId = blobs?.[0]?.id;
    if (!blobId) {
      throw new Error('文件上传失败,未取得 blobId');
    }
    attachmentId = await bindImportAttachment(sessionId, blobId);
    const payload = await startMaterialImport(attachmentId);
    applyState(payload);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function revise() {
  if (!state.value) return;
  if (!hasChanges.value) {
    message.info('未检测到修改');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    const payload = await reviewMaterialImport(taskId.value, {
      action: 'revise',
      corrections: corrections.value,
      expected_revision: state.value.revision,
    });
    applyState(payload);
    message.success('已按修改重新预检,请核对结果');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function confirmSubmit() {
  if (!state.value) return;
  if (hasChanges.value) {
    message.warning('存在未提交的修改,请先点「重新检查」并按新的预检结果确认');
    return;
  }
  if (!canCommit.value) {
    message.warning(lastError.value || '预检仍有阻断项,请修订后重新检查');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    const payload = await reviewMaterialImport(taskId.value, {
      action: 'confirm',
      expected_preflight_hash: state.value.preflight_hash,
      expected_revision: state.value.revision,
    });
    applyState(payload);
    if (receipt.value) {
      message.success('提交完成,请查看回执');
    } else if (lastError.value) {
      message.error(lastError.value);
    }
  } catch (error) {
    const text = agentImportErrorMessage(error);
    message.error(text);
    // æäº¤è¶…æ—¶/网关中断时命令可能已执行,提示用户查回执,避免重复提交
    lastError.value = text;
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function fetchReceipt() {
  if (!taskId.value) return;
  processing.value = true;
  modalApi.lock();
  try {
    receipt.value = await getMaterialImportReceipt(taskId.value);
    step.value = 3;
    syncFooter();
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function handleCancel() {
  if (step.value === 2 && taskId.value) {
    try {
      await reviewMaterialImport(taskId.value, {
        action: 'cancel',
        expected_revision: state.value?.revision ?? 0,
      });
    } catch {
      // å–消失败不影响关闭窗口
    }
  }
  modalApi.close();
}
function finish() {
  if ((receipt.value?.summary.success ?? 0) > 0) {
    emit('success');
  }
  modalApi.close();
}
async function handlePrimary() {
  if (processing.value) return;
  if (step.value === 1) {
    await startImport();
    return;
  }
  if (step.value === 2) {
    await confirmSubmit();
    return;
  }
  finish();
}
const [Modal, modalApi] = useVbenModal({
  onCancel() {
    void handleCancel();
  },
  onClosed() {
    reset();
  },
  onConfirm() {
    void handlePrimary();
  },
  onOpenChange(isOpen) {
    if (isOpen) {
      reset();
      syncFooter();
    }
  },
});
</script>
<template>
  <Modal title="智能体导入物料" class="w-[1080px]">
    <Steps
      :current="step - 1"
      class="mx-4 mb-4"
      size="small"
      :items="[
        { title: '上传文件' },
        { title: '审核确认' },
        { title: '提交回执' },
      ]"
    />
    <div v-if="step === 1" class="mx-4">
      <Upload
        accept=".csv,.xlsx"
        :max-count="1"
        :show-upload-list="false"
        :before-upload="beforeUpload"
      >
        <Button type="primary">选择 CSV / XLSX æ–‡ä»¶</Button>
      </Upload>
      <div v-if="fileName" class="mt-2 text-sm text-gray-600">
        å·²é€‰æ‹©ï¼š{{ fileName }}
      </div>
      <div class="mt-3 text-xs text-gray-500 leading-relaxed">
        <div>文件上限 8 MiB,仅支持 CSV / XLSX。</div>
        <div>
          æµç¨‹ï¼šè§£æžæ¸…单 â†’ Java æ ¡éªŒåˆ†ç±»/单位/编码 â†’ äººå·¥æ ¸å¯¹ â†’ ç¡®è®¤æäº¤ã€‚
        </div>
        <div>
          æœ¬é˜¶æ®µåªæ–°å»ºç‰©æ–™ï¼šç¼–码已存在会跳过,不更新原物料,也不同步 MES。
        </div>
      </div>
    </div>
    <div v-else-if="step === 2" class="mx-4">
      <div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
        <span>总计:{{ preflight?.summary.total ?? rows.length }}</span>
        <span class="text-green-600">
          æ–°å»ºï¼š{{ preflight?.summary.create ?? 0 }}
        </span>
        <span class="text-orange-500">
          è·³è¿‡ï¼š{{ preflight?.summary.skip ?? 0 }}
        </span>
        <span class="text-red-500">
          é˜»æ–­ï¼š{{ preflight?.summary.blocked ?? 0 }}
        </span>
        <span class="text-gray-500">方案版本:{{ state?.revision ?? 0 }}</span>
      </div>
      <Alert
        v-if="lastError"
        type="error"
        :message="lastError"
        show-icon
        class="mb-3"
      />
      <Alert
        v-if="issues.length > 0"
        type="warning"
        show-icon
        class="mb-3"
        message="存在需要处理的问题"
      >
        <template #description>
          <div v-for="(issue, index) in issues" :key="index">
            {{ issue.message }}
          </div>
        </template>
      </Alert>
      <Alert
        v-else-if="preflight && !preflight.can_commit"
        type="warning"
        show-icon
        class="mb-3"
        message="预检仍有阻断项,修正后请点「重新检查」,通过后才能确认提交"
      />
      <Table
        bordered
        size="small"
        :columns="reviewColumns"
        :data-source="rows"
        :pagination="false"
        :row-key="(record: EditableRow) => record.candidate_id"
        :scroll="{ y: 360 }"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="isEditableColumn(column.key)">
            <Input
              v-model:value="record[column.key]"
              size="small"
              :status="
                record.preflight?.blockers?.some(
                  (item: MdmAgentImportApi.Issue) => item.field === column.key,
                )
                  ? 'error'
                  : undefined
              "
            />
            <div
              v-if="
                column.key === 'categoryCode' &&
                record.preflight?.resolved?.category_name
              "
              class="mt-1 text-xs text-gray-400"
            >
              è§£æžä¸ºï¼š{{ record.preflight.resolved.category_name }}
            </div>
            <div
              v-if="
                column.key === 'unitMeasureCode' &&
                record.preflight?.resolved?.unit_measure_name
              "
              class="mt-1 text-xs text-gray-400"
            >
              è§£æžä¸ºï¼š{{ record.preflight.resolved.unit_measure_name }}
            </div>
          </template>
          <template v-else-if="column.key === 'action'">
            <Tag
              v-if="record.preflight"
              :color="
                ACTION_COLOR[record.preflight.action as MdmAgentImportApi.RowAction]
              "
            >
              {{ ACTION_LABEL[record.preflight.action as MdmAgentImportApi.RowAction] }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'message'">
            <div
              v-for="(item, index) in rowMessages(record as EditableRow)"
              :key="index"
              class="text-xs leading-relaxed"
              :class="item.type === 'error' ? 'text-red-500' : 'text-orange-500'"
            >
              {{ item.text }}
            </div>
          </template>
        </template>
      </Table>
      <div class="mt-3 flex flex-wrap items-center gap-2">
        <Button :disabled="processing || !hasChanges" @click="revise">
          é‡æ–°æ£€æŸ¥
        </Button>
        <Button :disabled="processing || !taskId" @click="fetchReceipt">
          æŸ¥è¯¢å›žæ‰§
        </Button>
        <span class="text-xs text-gray-400">
          {{ hasChanges ? '已修改,需重新检查后才能确认' : '确认后提交,已存在编码会跳过' }}
        </span>
      </div>
    </div>
    <div v-else class="mx-4">
      <div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
        <Tag :color="receipt?.status === 'SUCCEEDED' ? 'green' : 'orange'">
          {{ receipt?.status ?? 'UNKNOWN' }}
        </Tag>
        <span>总计:{{ receipt?.summary.total ?? 0 }}</span>
        <span class="text-green-600">
          æˆåŠŸï¼š{{ receipt?.summary.success ?? 0 }}
        </span>
        <span class="text-orange-500">
          è·³è¿‡ï¼š{{ receipt?.summary.skipped ?? 0 }}
        </span>
        <span class="text-red-500">
          å¤±è´¥ï¼š{{ receipt?.summary.failed ?? 0 }}
        </span>
        <span class="text-gray-500">
          æœªçŸ¥ï¼š{{ receipt?.summary.unknown ?? 0 }}
        </span>
      </div>
      <Alert
        v-if="receipt?.error_message"
        type="error"
        :message="receipt.error_message"
        show-icon
        class="mb-3"
      />
      <Table
        bordered
        size="small"
        :columns="receiptColumns"
        :data-source="receipt?.rows ?? []"
        :pagination="false"
        :row-key="(record: MdmAgentImportApi.ReceiptRow) => record.candidate_id"
        :scroll="{ y: 360 }"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'action'">
            <Tag :color="record.action === 'CREATE' ? 'green' : 'orange'">
              {{ record.action === 'CREATE' ? '新建' : '跳过' }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'status'">
            <Tag :color="STATUS_COLOR[record.status as MdmAgentImportApi.RowStatus]">
              {{ STATUS_LABEL[record.status as MdmAgentImportApi.RowStatus] }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'error'">
            <span class="text-xs text-red-500">
              {{ record.error_message ?? '' }}
            </span>
          </template>
        </template>
      </Table>
    </div>
  </Modal>
</template>
src/views/basicData/mdm/modules/import-form.vue
@@ -1,18 +1,28 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { h } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, notification, Upload } from 'ant-design-vue';
import { Button, message, 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';
/** å¯¼å…¥ç»“果,对应后端 MdmItemImportRespVO */
interface ImportResult {
  /** æ–°å¢žæˆåŠŸçš„ç‰©æ–™ */
  createList?: string[];
  /** æ›´æ–°æˆåŠŸçš„ç‰©æ–™ */
  updateList?: string[];
  /** å¤±è´¥æ˜Žç»†ï¼škey ä¸ºã€Œç‰©æ–™ç¼–码 åç§°ã€ï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  */
  failureList?: Record<string, string>;
}
const emit = defineEmits(['success']);
@@ -26,6 +36,17 @@
  showDefaultActions: false,
});
const importResult = ref<ImportResult>({});
const createCount = computed(() => importResult.value.createList?.length ?? 0);
const updateCount = computed(() => importResult.value.updateList?.length ?? 0);
const failureEntries = computed(() =>
  Object.entries(importResult.value.failureList ?? {}),
);
const failureCount = computed(() => failureEntries.value.length);
// æ³¨æ„ï¼šæœ¬ç»„件里的多个 useVbenModal æœ‰å…ˆåŽé¡ºåºè¦æ±‚——
// çˆ¶é¡µé¢ç”¨ useVbenModal({ connectedComponent: ImportForm }) é€šè¿‡ provide/inject ç»‘定,
// åªæœ‰ç¬¬ä¸€ä¸ªè°ƒç”¨ä¼šæŽ¥ç®¡è¿™ä¸ªç»‘定。所以导入窗口必须声明在最前,其余弹框(如结果弹框)排在后面。
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
@@ -37,29 +58,45 @@
    const data = await formApi.getValues();
    try {
      const response = (await importItem(data.file, data.updateSupport)) as any;
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      const resultData = (response?.data || response || {}) as ImportResult;
      const hasFailure = Object.keys(resultData.failureList ?? {}).length > 0;
      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,
        });
      if (hasFailure) {
        // ä¸èƒ½åœ¨è¿™é‡Œå…ˆå…³å¯¼å…¥çª—口:父页面配置了 destroyOnClose,
        // å…³çª—会把本组件整个重建,刚打开的结果弹框会被连带卸载(闪一下就消失)。
        // æ”¹ä¸ºæŠŠç»“果弹框叠在上面,等它关闭后再关导入窗口(见下面 onClosed)。
        importResult.value = resultData;
        resultModalApi.open();
      } else {
        importResult.value = {};
        message.success($t('ui.actionMessage.operationSuccess'));
        await modalApi.close();
      }
    } finally {
      modalApi.unlock();
    }
  },
});
/** å¯¼å…¥ç»“果弹框:失败明细可能很长,弹框内固定高度滚动,避免像通知条那样撑出屏幕 */
const [ResultModal, resultModalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (isOpen) {
      resultModalApi.setState({
        confirmText: '知道了',
        showCancelButton: false,
      });
    }
  },
  // å¿…须显式提供:ModalApi.onConfirm æ˜¯ this.api.onConfirm?.() çš„可选调用,不传则点按钮毫无反应
  onConfirm() {
    resultModalApi.close();
  },
  // ç»“果弹框关闭后再关导入窗口(此时本组件被销毁是安全的)
  onClosed() {
    modalApi.close();
  },
});
@@ -102,4 +139,46 @@
      </div>
    </template>
  </Modal>
  <ResultModal title="导入结果" class="w-[760px]">
    <div class="mx-4">
      <div class="mb-3 flex flex-wrap gap-x-6 gap-y-1 text-sm">
        <span>
          æ–°å¢žï¼š<span class="font-medium text-green-600">{{ createCount }}</span>
          æ¡
        </span>
        <span>
          æ›´æ–°ï¼š<span class="font-medium text-blue-600">{{ updateCount }}</span>
          æ¡
        </span>
        <span>
          å¤±è´¥ï¼š<span class="font-medium text-red-600">{{ failureCount }}</span>
          æ¡
        </span>
      </div>
      <div
        v-if="failureCount > 0"
        class="overflow-hidden rounded-md border border-red-100"
      >
        <div
          class="border-b border-red-100 bg-red-50 px-3 py-2 text-sm font-medium text-red-600"
        >
          å¤±è´¥æ˜Žç»†ï¼ˆå…± {{ failureCount }} æ¡ï¼Œä¸‹æ–¹åŒºåŸŸå¯æ»šåŠ¨æŸ¥çœ‹ï¼‰
        </div>
        <div class="max-h-[60vh] overflow-y-auto">
          <div
            v-for="[itemKey, itemReason] in failureEntries"
            :key="itemKey"
            class="flex gap-2 border-b border-gray-100 px-3 py-2 text-sm last:border-b-0"
          >
            <span class="w-56 shrink-0 break-all text-gray-700">
              {{ itemKey }}
            </span>
            <span class="break-all text-red-500">{{ itemReason }}</span>
          </div>
        </div>
      </div>
    </div>
  </ResultModal>
</template>
src/views/basicData/mdm/unit/data.ts
@@ -119,7 +119,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/basicData/mdm/unit/index.vue
@@ -122,14 +122,14 @@
              label: $t('ui.actionTitle.import', ['计量单位']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mdm:unit-measure:import'],
              auth: ['mdm:unit:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mdm:unit-measure:export'],
              auth: ['mdm:unit:export'],
              onClick: handleExport,
            },
          ]"
src/views/bi/config/chart/data.ts
@@ -201,7 +201,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 170,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/bi/config/data-source/data.ts
@@ -127,7 +127,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 170,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/bi/dashboard/chart-options.ts
@@ -40,13 +40,17 @@
  xData: string[],
  series: Array<{ name: string; data: number[] }>,
  colors: string[] = [C.cyan, C.blue, C.purple],
  horizontal = false,
): EChartsOption {
  return {
    grid: { bottom: 40, left: 48, right: 16, top: 16 },
    grid: { bottom: 40, left: horizontal ? 140 : 48, right: 16, top: 16 },
    legend: { ...darkLegend, bottom: 0 },
    tooltip: { ...darkTooltip, axisPointer: { type: 'shadow' }, trigger: 'axis' },
    xAxis: { ...darkAxis, data: xData, type: 'category' },
    yAxis: { ...darkAxis, type: 'value' },
    xAxis: horizontal ? { ...darkAxis, type: 'value' } : { ...darkAxis, data: xData, type: 'category' },
    yAxis: horizontal ? {
      ...darkAxis, data: xData, type: 'category', inverse: true,
      axisLabel: { ...darkAxis.axisLabel, width: 125, overflow: 'truncate', interval: 0 },
    } : { ...darkAxis, type: 'value' },
    series: series.map((s, i) => ({
      barGap: '30%',
      barMaxWidth: 36,
src/views/bi/dashboard/data.ts
@@ -13,7 +13,7 @@
  'purchase-sales': {
    code: 'purchase_sales',
    title: '采购 Â· é”€å”® Â· å”®åŽ',
    subtitle: '供应链全链路经营决策看板',
    subtitle: '客户经营 Â· å•†æœºè·Ÿè¿› Â· åˆåŒå›žæ¬¾ Â· é‡‡è´­å”®åŽ',
    gradient: 'from-indigo-600 via-blue-600 to-cyan-500',
    icon: 'lucide:shopping-cart',
  },
src/views/bi/dashboard/index.vue
@@ -12,6 +12,7 @@
import ChartCard from './modules/ChartCard.vue';
import ClockWidget from './modules/ClockWidget.vue';
import RealtimeTable from './modules/RealtimeTable.vue';
import { DASHBOARD_META, getDashboardCode, getDefaultCharts } from './data';
defineOptions({ name: 'BiDashboard' });
@@ -23,27 +24,53 @@
const charts = ref<BiDashboardApi.ChartItem[]>([]);
const loading = ref(true);
const lastUpdateTime = ref('');
let dataRefreshTimer: ReturnType<typeof setInterval> | undefined;
let requestSequence = 0;
let fetching = false;
let disposed = false;
async function loadData() {
  if (!meta.value) return;
async function loadData(background = false) {
  if (!meta.value || disposed || (background && fetching)) return;
  const sequence = ++requestSequence;
  const code = meta.value.code;
  fetching = true;
  if (!background && dataRefreshTimer) clearInterval(dataRefreshTimer);
  try {
    loading.value = true;
    const data = await getDashboardData(meta.value.code);
    if (!background) loading.value = true;
    const data = await getDashboardData(code);
    if (disposed || sequence !== requestSequence) return;
    charts.value = data.length > 0 ? data : getDefaultCharts();
    lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
  } catch {
    charts.value = getDefaultCharts();
    if (!background && !disposed && sequence === requestSequence) charts.value = getDefaultCharts();
  } finally {
    loading.value = false;
    await nextTick();
    resizeAllCharts();
    if (!disposed && sequence === requestSequence) {
      fetching = false;
      loading.value = false;
      await nextTick();
      if (!disposed && sequence === requestSequence) {
        resizeAllCharts();
        if (!background) {
          const intervals = charts.value.map((chart) => chart.refreshInterval).filter((interval) => interval > 0);
          if (intervals.length) dataRefreshTimer = setInterval(() => {
            if (!loading.value && document.visibilityState === 'visible') loadData(true);
          }, Math.max(15, Math.min(...intervals)) * 1000);
        }
      }
    }
  }
}
// ======== åˆ†ç±» ========
const numberCards = computed(() => charts.value.filter((c) => c.chartType === 'number'));
const tableCharts = computed(() => charts.value.filter((c) => c.chartType === 'table'));
const echartsCharts = computed(() => charts.value.filter((c) => !['number', 'table'].includes(c.chartType)));
const displayCharts = computed(() => {
  if (dashboardCode.value !== 'purchase-sales') return charts.value;
  return [...charts.value].sort((a, b) =>
    Number(b.chartOptions?.dashboardSection === 'crm') - Number(a.chartOptions?.dashboardSection === 'crm'),
  );
});
const numberCards = computed(() => displayCharts.value.filter((c) => c.chartType === 'number'));
const tableCharts = computed(() => displayCharts.value.filter((c) => c.chartType === 'table'));
const echartsCharts = computed(() => displayCharts.value.filter((c) => !['number', 'table'].includes(c.chartType)));
// ======== å›¾è¡¨ Grid åˆ—跨度 ========
function isFullWidth(chart: BiDashboardApi.ChartItem): boolean {
@@ -102,6 +129,7 @@
    const progress = Math.min((currentTime - startTime) / duration, 1);
    const eased = 1 - (1 - progress) ** 3;
    el.textContent = (end * eased).toFixed(decimals);
    if (progress === 1) el.textContent = formatNumber(end);
    if (progress < 1) requestAnimationFrame(update);
  }
  requestAnimationFrame(update);
@@ -110,7 +138,7 @@
function maybeAnimate(el: HTMLElement, cardId: number, value: number) {
  if (animatedIds.has(cardId)) return;
  animatedIds.add(cardId);
  animateValue(el, value, 0);
  animateValue(el, value, Number.isInteger(value) ? 0 : 2);
}
watch(() => charts.value, () => {
@@ -134,8 +162,16 @@
  return formatNumber(v);
}
// å¤§å±è¡¨æ ¼é‡Œçš„æ—¥æœŸåˆ—只展示到天(形如 2026-09-01 10:23:45 çš„值切成 2026-09-01),
// å›¾è¡¨ SQL é‡Œçš„ order_time / receipt_date / create_time / hire_date éƒ½æ˜¯å¸¦æ—¶åˆ†ç§’çš„
const DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
function getCellValue(row: unknown, col: string): unknown {
  return (row as Record<string, unknown>)[col];
  const val = (row as Record<string, unknown>)[col];
  if (typeof val === 'string' && DATE_TIME_RE.test(val)) {
    return val.slice(0, 10);
  }
  return val;
}
// ======== çœ‹æ¿ä¸»é¢˜è‰² ========
@@ -149,6 +185,10 @@
// ======== KPI å›¾æ ‡/颜色 ========
function getKpiIcon(name: string): string {
  if (name.includes('客户')) return 'lucide:users';
  if (name.includes('商机')) return 'lucide:target';
  if (name.includes('回款')) return 'lucide:wallet';
  if (name.includes('报价')) return 'lucide:receipt-text';
  if (name.includes('采购')) return 'lucide:shopping-cart';
  if (name.includes('销售')) return 'lucide:trending-up';
  if (name.includes('售后')) return 'lucide:headphones';
@@ -173,6 +213,9 @@
  loadData();
});
onBeforeUnmount(() => {
  disposed = true;
  requestSequence++;
  if (dataRefreshTimer) clearInterval(dataRefreshTimer);
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  resizeObserver?.disconnect();
});
@@ -241,7 +284,7 @@
            <IconifyIcon :icon="getKpiIcon(card.name)" />
          </div>
          <div class="kpi-body">
            <span class="kpi-label">{{ card.name }}</span>
            <span class="kpi-label" :title="card.name">{{ card.name }}</span>
            <span
              class="kpi-value"
              :ref="(el: unknown) => {
@@ -278,30 +321,11 @@
            <span class="table-header-title">{{ chart.name }}</span>
            <span class="table-badge">实时滚动</span>
          </div>
          <div v-if="chart.data && chart.data.length > 0" class="table-wrap">
            <table class="data-table">
              <thead>
                <tr>
                  <th
                    v-for="key in Object.keys(chart.data[0] || {})"
                    :key="key"
                  >{{ key }}</th>
                </tr>
              </thead>
              <tbody>
                <tr
                  v-for="(row, ri) in chart.data"
                  :key="ri"
                  :style="{ animationDelay: `${ri * 50}ms` }"
                >
                  <td
                    v-for="key in Object.keys(chart.data[0] || {})"
                    :key="key"
                  >{{ getCellValue(row, key) }}</td>
                </tr>
              </tbody>
            </table>
          </div>
          <RealtimeTable
            v-if="chart.data && chart.data.length > 0"
            :rows="chart.data"
            :format-cell="getCellValue"
          />
          <Empty v-else description="暂无数据" />
        </div>
      </template>
@@ -506,7 +530,7 @@
.kpi-label {
  font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  white-space: normal; overflow-wrap: anywhere; line-height: 1.4;
}
.kpi-value {
@@ -579,50 +603,6 @@
  padding: 2px 8px; border-radius: 10px;
  background: rgba(0,255,136,0.08); color: var(--green);
  border: 1px solid rgba(0,255,136,0.15);
}
.table-wrap {
  overflow-x: auto; border-radius: 8px;
  background: rgba(4, 14, 36, 0.55);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  border: 1px solid rgba(0, 229, 255, 0.08);
}
.data-table {
  width: 100%; border-collapse: collapse; font-size: 12px;
}
.data-table thead th {
  background: rgba(6, 18, 42, 0.8);
  color: var(--text-secondary);
  font-weight: 600; font-size: 10px;
  text-transform: uppercase; letter-spacing: 0.8px;
  padding: 10px 14px; text-align: left;
  border-bottom: 1px solid rgba(0, 229, 255, 0.12);
  white-space: nowrap;
}
.data-table tbody td {
  padding: 9px 14px; color: var(--text-primary);
  border-bottom: 1px solid rgba(0, 229, 255, 0.04);
  white-space: nowrap;
}
.data-table tbody tr {
  transition: all 0.25s;
  opacity: 0;
  animation: row-fade-in 0.45s ease forwards;
}
.data-table tbody tr:hover {
  background: rgba(0, 229, 255, 0.06);
  box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03);
}
@keyframes row-fade-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}
/* ======== ç©ºçŠ¶æ€ ======== */
src/views/bi/dashboard/modules/ChartCard.vue
@@ -41,12 +41,16 @@
  const valueKey = keys[1] || 'value';
  const labels = chart.data.map((d: Record<string, unknown>) => String(d[labelKey] || ''));
  const values = chart.data.map((d: Record<string, unknown>) => Number(d[valueKey]) || 0);
  const series = keys.slice(1).map((key) => ({
    name: keys.length > 2 ? key : chart.name,
    data: chart.data!.map((row) => Number(row[key]) || 0),
  }));
  switch (chart.chartType) {
    case 'bar':
      return getBarChartOptions(labels, [{ name: chart.name, data: values }]);
      return getBarChartOptions(labels, series, undefined, chart.chartOptions?.horizontal === true);
    case 'line':
      return getAreaLineChartOptions(labels, [{ name: chart.name, data: values }]);
      return getAreaLineChartOptions(labels, series);
    case 'pie':
      return getDonutPieChartOptions(labels.map((l, i) => ({ name: l, value: values[i] || 0 })));
    case 'gauge': {
@@ -125,6 +129,9 @@
    <div v-if="chart.error" class="flex h-[240px] items-center justify-center">
      <Empty :description="chart.error" />
    </div>
    <div v-else-if="!chart.data?.length" class="flex h-[240px] items-center justify-center">
      <Empty description="暂无业务数据" />
    </div>
    <EchartsUI
      v-else
      ref="chartRef"
src/views/bi/dashboard/modules/RealtimeTable.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
const props = withDefaults(defineProps<{
  rows: Record<string, unknown>[];
  formatCell?: (row: Record<string, unknown>, column: string) => unknown;
  maxHeight?: number;
  speed?: number;
}>(), { maxHeight: 260, speed: 24 });
const columns = computed(() => Object.keys(props.rows[0] ?? {}));
const viewport = ref<HTMLElement>();
const track = ref<HTMLElement>();
const original = ref<HTMLElement>();
const scrolling = ref(false);
const hovered = ref(false);
const focused = ref(false);
const reducedMotion = ref(false);
let animation: Animation | undefined;
let observer: ResizeObserver | undefined;
let motionQuery: MediaQueryList | undefined;
let frame = 0;
let distance = 0;
let duration = 0;
let active = true;
let disposed = false;
let measuring = false;
function syncPlayback() {
  if (!animation) return;
  if (hovered.value || focused.value || document.hidden || !active) animation.pause();
  else animation.play();
}
async function measure() {
  if (disposed || measuring || !viewport.value || !original.value || !track.value) return;
  measuring = true;
  try {
    const height = original.value.offsetHeight;
    const shouldScroll = !reducedMotion.value && height > viewport.value.clientHeight + 1;
    const nextDuration = height / Math.max(1, props.speed) * 1000;
    if (shouldScroll && animation && height === distance && duration === nextDuration) return;
    const offset = distance && duration
      ? (Number(animation?.currentTime ?? 0) % duration) / duration * distance : 0;
    animation?.cancel();
    animation = undefined;
    scrolling.value = shouldScroll;
    distance = height;
    duration = nextDuration;
    await nextTick();
    if (disposed || !shouldScroll || !track.value) return;
    // Animate a normal block, not tbody. A second identical table fills the loop seam.
    animation = track.value.animate([
      { transform: 'translateY(0)' },
      { transform: `translateY(-${height}px)` },
    ], { duration, iterations: Infinity, easing: 'linear' });
    animation.currentTime = (offset % height) / height * duration;
    syncPlayback();
  } finally {
    measuring = false;
  }
}
function scheduleMeasure() {
  cancelAnimationFrame(frame);
  frame = requestAnimationFrame(() => { void measure(); });
}
function onMotionChange() {
  reducedMotion.value = motionQuery?.matches ?? false;
  scheduleMeasure();
}
function onKeydown(event: KeyboardEvent) {
  if (!animation || !scrolling.value) return;
  const step = event.key === 'ArrowDown' ? 40 : event.key === 'ArrowUp' ? -40 : 0;
  if (!step) return;
  event.preventDefault();
  const nextTime = Number(animation.currentTime ?? 0) + step / Math.max(1, props.speed) * 1000;
  animation.currentTime = ((nextTime % duration) + duration) % duration;
}
watch(() => [props.rows, props.maxHeight, props.speed], scheduleMeasure, { flush: 'post' });
watch([hovered, focused], syncPlayback);
onMounted(() => {
  motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
  motionQuery.addEventListener('change', onMotionChange);
  onMotionChange();
  observer = new ResizeObserver(scheduleMeasure);
  if (viewport.value) observer.observe(viewport.value);
  if (original.value) observer.observe(original.value);
  document.addEventListener('visibilitychange', syncPlayback);
});
onActivated(() => { active = true; scheduleMeasure(); syncPlayback(); });
onDeactivated(() => { active = false; syncPlayback(); });
onBeforeUnmount(() => {
  disposed = true;
  cancelAnimationFrame(frame);
  animation?.cancel();
  observer?.disconnect();
  motionQuery?.removeEventListener('change', onMotionChange);
  document.removeEventListener('visibilitychange', syncPlayback);
});
</script>
<template>
  <div
    class="realtime-table"
    role="region"
    aria-label="实时数据表,聚焦或悬停暂停滚动,方向键逐行查看"
    tabindex="0"
    :style="{ '--table-height': `${maxHeight}px`, '--table-width': `${columns.length * 120}px` }"
    @mouseenter="hovered = true"
    @mouseleave="hovered = false"
    @focusin="focused = true"
    @focusout="focused = false"
    @keydown="onKeydown"
  >
    <div class="table-content">
      <table class="header-table">
        <thead><tr><th v-for="column in columns" :key="column" scope="col">{{ column }}</th></tr></thead>
      </table>
      <div ref="viewport" class="table-viewport" :class="{ 'is-scrolling': scrolling }">
        <div ref="track" class="table-track" :class="{ 'is-scrolling': scrolling }">
          <div ref="original">
            <table>
              <thead class="sr-only"><tr><th v-for="column in columns" :key="column" scope="col">{{ column }}</th></tr></thead>
              <tbody>
                <tr v-for="(row, index) in rows" :key="index">
                  <td v-for="column in columns" :key="column" :title="String(formatCell ? formatCell(row, column) ?? '' : row[column] ?? '')">
                    {{ formatCell ? formatCell(row, column) : row[column] }}
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
          <table v-if="scrolling" aria-hidden="true">
            <tbody>
              <tr v-for="(row, index) in rows" :key="index">
                <td v-for="column in columns" :key="column">
                  {{ formatCell ? formatCell(row, column) : row[column] }}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.realtime-table { overflow-x: auto; border: 1px solid rgba(0, 229, 255, 0.08); border-radius: 8px; background: rgba(4, 14, 36, 0.55); }
.realtime-table:focus-visible { outline: 2px solid var(--cyan, #00e5ff); outline-offset: 2px; }
.table-content { min-width: var(--table-width); }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
th { padding: 10px 14px; background: rgb(6, 18, 42); color: var(--text-secondary, #b9c4dc); font-size: 10px; font-weight: 600; text-align: left; border-bottom: 1px solid rgba(0, 229, 255, 0.12); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
td { padding: 9px 14px; color: var(--text-primary, #ebf0fc); border-bottom: 1px solid rgba(0, 229, 255, 0.04); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
tbody tr:hover { background: rgba(0, 229, 255, 0.06); }
.table-viewport { max-height: var(--table-height); overflow-y: auto; scrollbar-width: thin; }
.table-viewport.is-scrolling { overflow-y: hidden; }
.table-track.is-scrolling { will-change: transform; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); }
</style>
src/views/bi/warehouse/index.vue
@@ -17,6 +17,7 @@
} from '#/api/bi/warehouseCoordinate';
import ClockWidget from '../dashboard/modules/ClockWidget.vue';
import RealtimeTable from '../dashboard/modules/RealtimeTable.vue';
import {
  getChinaMapOptions,
  getWarehouseAreaLineOptions,
@@ -30,19 +31,29 @@
const charts = ref<BiDashboardApi.ChartItem[]>([]);
const loading = ref(true);
const lastUpdateTime = ref('');
let fetching = false;
let disposed = false;
async function loadData() {
async function loadData(background = false) {
  if (fetching || disposed) return false;
  fetching = true;
  try {
    loading.value = true;
    if (!background) loading.value = true;
    const data = await getDashboardData('warehouse');
    if (disposed) return false;
    charts.value = data.length > 0 ? data : [];
    lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
    return true;
  } catch {
    charts.value = [];
    if (!background && !disposed) charts.value = [];
    return false;
  } finally {
    loading.value = false;
    await nextTick();
    resizeAllCharts();
    fetching = false;
    if (!disposed) {
      loading.value = false;
      await nextTick();
      if (!disposed) resizeAllCharts();
    }
  }
}
@@ -233,10 +244,7 @@
// ======== è¡¨æ ¼æ•°æ® ========
const tableData = computed(() => tableChart.value?.data || []);
const tableColumns = computed(() => {
  if (tableData.value.length === 0) return [];
  return Object.keys(tableData.value[0] || {});
});
// ======== åº“存预警 ========
const warningList = computed(() => {
@@ -428,7 +436,8 @@
function setupTimer() {
  clearTimer();
  refreshTimer = setInterval(async () => {
    await loadData();
    if (document.hidden || editMode.value || disposed) return;
    if (!(await loadData(true))) return;
    await nextTick();
    await renderAllCharts();
    if (editMode.value) await nextTick().then(() => attachMapListeners());
@@ -497,13 +506,16 @@
  resizeObserver = new ResizeObserver(() => resizeAllCharts());
  if (dashboardRef.value) resizeObserver.observe(dashboardRef.value);
  await loadCoordinates();
  if (disposed) return;
  await loadData();
  if (disposed) return;
  await nextTick();
  await renderAllCharts();
  setupTimer();
});
onBeforeUnmount(() => {
  disposed = true;
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  resizeObserver?.disconnect();
  clearTimer();
@@ -720,26 +732,11 @@
          æœ€è¿‘入库记录
          <span class="wh-table-badge">实时滚动</span>
        </div>
        <div v-if="tableData.length > 0" class="wh-table-wrap">
          <table class="wh-table">
            <thead>
              <tr>
                <th v-for="col in tableColumns" :key="col">{{ col }}</th>
              </tr>
            </thead>
            <tbody>
              <tr
                v-for="(row, ri) in tableData"
                :key="ri"
                :style="{ animationDelay: `${ri * 50}ms` }"
              >
                <td v-for="col in tableColumns" :key="col">
                  {{ getCellValue(row, col) }}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
        <RealtimeTable
          v-if="tableData.length > 0"
          :rows="tableData"
          :format-cell="getCellValue"
        />
        <Empty v-else description="暂无数据" />
      </div>
    </template>
@@ -1187,50 +1184,6 @@
  padding: 2px 8px; border-radius: 10px;
  background: rgba(0,255,136,0.08); color: var(--green);
  border: 1px solid rgba(0,255,136,0.15);
}
.wh-table-wrap {
  overflow-x: auto; border-radius: 8px;
  background: rgba(4, 14, 36, 0.55);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  border: 1px solid rgba(0, 229, 255, 0.08);
}
.wh-table {
  width: 100%; border-collapse: collapse; font-size: 12px;
}
.wh-table thead th {
  background: rgba(6, 18, 42, 0.8);
  color: var(--text-secondary);
  font-weight: 600; font-size: 10px;
  text-transform: uppercase; letter-spacing: 0.8px;
  padding: 10px 14px; text-align: left;
  border-bottom: 1px solid rgba(0,229,255,0.12);
  white-space: nowrap;
}
.wh-table tbody td {
  padding: 9px 14px; color: var(--text-primary);
  border-bottom: 1px solid rgba(0, 229, 255, 0.04);
  white-space: nowrap;
}
.wh-table tbody tr {
  transition: all 0.25s;
  opacity: 0;
  animation: row-fade-in 0.45s ease forwards;
}
.wh-table tbody tr:hover {
  background: rgba(0, 229, 255, 0.06);
  box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03);
}
@keyframes row-fade-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}
/* ======== å…¨å± ======== */
src/views/bpm/model/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { ModelCategoryInfo } from '#/api/bpm/model';
import {
@@ -196,6 +197,7 @@
  globalAutoApproveLoading.value = true;
  try {
    await updateGlobalAutoApprove(checked);
    automaticApproval.value = checked;
    message.success(
      checked
        ? '已开启全局免审批:所有流程提交后将直接通过'
src/views/bpm/oa/leave/create.vue
@@ -18,6 +18,7 @@
import { getApprovalDetail as getApprovalDetailApi } from '#/api/bpm/processInstance';
import { $t } from '#/locales';
import { router } from '#/router';
import { automaticApproval } from '#/store/audit-policy';
import ProcessInstanceTimeline from '#/views/bpm/processInstance/detail/modules/time-line.vue';
import { useFormSchema } from './data';
@@ -67,7 +68,7 @@
    return;
  }
  // 1.2 å®¡æ‰¹ç›¸å…³ï¼šæ ¡éªŒæŒ‡å®šå®¡æ‰¹äºº
  if (startUserSelectTasks.value?.length > 0) {
  if (!automaticApproval.value && startUserSelectTasks.value?.length > 0) {
    for (const userTask of startUserSelectTasks.value) {
      const assignees = startUserSelectAssignees.value[userTask.id];
      if (Array.isArray(assignees) && assignees.length === 0) {
@@ -79,7 +80,7 @@
  // æäº¤è¡¨å•
  const data = (await formApi.getValues()) as LeaveCreateData;
  // å®¡æ‰¹ç›¸å…³ï¼šè®¾ç½®æŒ‡å®šå®¡æ‰¹äºº
  if (startUserSelectTasks.value?.length > 0) {
  if (!automaticApproval.value && startUserSelectTasks.value?.length > 0) {
    data.startUserSelectAssignees = startUserSelectAssignees.value;
  }
  // æ ¼å¼åŒ–开始时间和结束时间的值
@@ -118,6 +119,7 @@
/** å®¡æ‰¹ç›¸å…³ï¼šèŽ·å–å®¡æ‰¹è¯¦æƒ… */
async function getApprovalDetail() {
  if (automaticApproval.value) return;
  processTimeLineLoading.value = true;
  try {
    const data = await getApprovalDetailApi({
@@ -144,7 +146,7 @@
        BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
    );
    // æ¢å¤ä¹‹å‰çš„选择审批人
    if (startUserSelectTasks.value?.length > 0) {
    if (!automaticApproval.value && startUserSelectTasks.value?.length > 0) {
      for (const node of startUserSelectTasks.value) {
        const tempAssignees = tempStartUserSelectAssignees.value[node.id];
        startUserSelectAssignees.value[node.id] = tempAssignees?.length
@@ -212,6 +214,10 @@
/** åˆå§‹åŒ– */
onMounted(async () => {
  if (automaticApproval.value) {
    if (query.id) await getDetail(Number(query.id));
    return;
  }
  const processDefinitionDetail: any = await getProcessDefinition(
    undefined,
    processDefineKey,
src/views/crm/backlog/index.vue
@@ -1,9 +1,12 @@
<script lang="ts" setup>
import { computed, onActivated, onMounted, ref } from 'vue';
import type { Ref } from 'vue';
import { computed, onActivated, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { Badge, Card, List } from 'ant-design-vue';
import { automaticApproval } from '#/store/audit-policy';
import { getFollowClueCount } from '#/api/crm/clue';
import {
@@ -49,6 +52,14 @@
  receivableAuditCount,
  receivablePlanRemindCount,
);
const visibleLeftSides = computed(() => leftSides.filter((item) =>
  !automaticApproval.value || !['contractAudit', 'receivableAudit'].includes(item.menu),
));
watch(automaticApproval, (automatic) => {
  if (automatic && ['contractAudit', 'receivableAudit'].includes(leftMenu.value)) {
    leftMenu.value = 'customerTodayContact';
  }
});
const currentComponent = computed(() => {
  const components = {
@@ -69,16 +80,32 @@
  leftMenu.value = item.menu;
}
/** èŽ·å–æ•°é‡ */
async function getCount() {
  customerTodayContactCount.value = await getTodayContactCustomerCount();
  customerPutPoolRemindCount.value = await getPutPoolRemindCustomerCount();
  customerFollowCount.value = await getFollowCustomerCount();
  clueFollowCount.value = await getFollowClueCount();
  contractAuditCount.value = await getAuditContractCount();
  contractRemindCount.value = await getRemindContractCount();
  receivableAuditCount.value = await getAuditReceivableCount();
  receivablePlanRemindCount.value = await getReceivablePlanRemindCount();
/** ç‹¬ç«‹åŠ è½½å„ç±»æ•°é‡ï¼Œå•ä¸ªæŽ¥å£å¤±è´¥ä¸é˜»æ–­å…¶ä»–å¾…åŠžã€‚ */
let countRequest: Promise<void> | undefined;
function getCount() {
  if (countRequest) return countRequest;
  const requests: [Ref<number>, () => Promise<number>][] = [
    [customerTodayContactCount, getTodayContactCustomerCount],
    [customerPutPoolRemindCount, getPutPoolRemindCustomerCount],
    [customerFollowCount, getFollowCustomerCount],
    [clueFollowCount, getFollowClueCount],
    [contractRemindCount, getRemindContractCount],
    [receivablePlanRemindCount, getReceivablePlanRemindCount],
  ];
  if (!automaticApproval.value) {
    requests.push(
      [contractAuditCount, getAuditContractCount],
      [receivableAuditCount, getAuditReceivableCount],
    );
  }
  countRequest = Promise.allSettled(
    requests.map(async ([count, request]) => {
      count.value = await request();
    }),
  ).then(() => undefined).finally(() => {
    countRequest = undefined;
  });
  return countRequest;
}
/** æ¿€æ´»æ—¶ */
@@ -95,7 +122,7 @@
  <Page auto-content-height>
    <div class="flex h-full w-full">
      <Card class="w-1/5">
        <List item-layout="horizontal" :data-source="leftSides">
        <List item-layout="horizontal" :data-source="visibleLeftSides">
          <template #renderItem="{ item }">
            <List.Item
              @click="sideClick(item)"
@@ -117,7 +144,7 @@
          </template>
        </List>
      </Card>
      <component class="ml-4 w-4/5" :is="currentComponent" />
      <component class="ml-4 w-4/5" :is="currentComponent" @refresh="getCount" />
    </div>
  </Page>
</template>
src/views/crm/backlog/modules/contract-audit-list.vue
@@ -5,7 +5,7 @@
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { Button, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
@@ -29,17 +29,21 @@
}
/** æ‰“开客户详情 */
function handleCustomerDetail(row: CrmContractApi.Contract) {
  push({ name: 'CrmCustomerDetail', params: { id: row.id } });
  if (row.customerId) push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** æ‰“开联系人详情 */
function handleContactDetail(row: CrmContractApi.Contract) {
  push({ name: 'CrmContactDetail', params: { id: row.id } });
  if (row.signContactId) push({ name: 'CrmContactDetail', params: { id: row.signContactId } });
}
/** æ‰“开商机详情 */
function handleBusinessDetail(row: CrmContractApi.Contract) {
  push({ name: 'CrmBusinessDetail', params: { id: row.id } });
  if (row.businessId) push({ name: 'CrmBusinessDetail', params: { id: row.businessId } });
}
function handleViewOrder(row: CrmContractApi.Contract) {
  if (row.orderId) push({ name: 'ErpSaleOrder', query: { id: row.orderId } });
}
const [Grid] = useVbenVxeGrid({
@@ -87,6 +91,11 @@
<template>
  <Grid>
    <template #no="{ row }">
      <Button type="link" @click="handleContractDetail(row)">
        {{ row.no }}
      </Button>
    </template>
    <template #name="{ row }">
      <Button type="link" @click="handleContractDetail(row)">
        {{ row.name }}
@@ -102,10 +111,23 @@
        {{ row.businessName }}
      </Button>
    </template>
    <template #contactName="{ row }">
    <template #signContactName="{ row }">
      <Button type="link" @click="handleContactDetail(row)">
        {{ row.contactName }}
        {{ row.signContactName }}
      </Button>
    </template>
    <template #auditStatus="{ row }">
      <Tag v-if="row.auditStatus === 0">未提交</Tag>
      <Tag v-else-if="row.auditStatus === 10" color="warning">审批中</Tag>
      <Tag v-else-if="row.auditStatus === 20" color="success">审核通过</Tag>
      <Tag v-else-if="row.auditStatus === 30" color="error">审核不通过</Tag>
      <Tag v-else-if="row.auditStatus === 40">已取消</Tag>
      <Tag v-else-if="row.auditStatus === 50" color="error">已作废</Tag>
      <span v-else>—</span>
    </template>
    <template #orderNo="{ row }">
      <Button v-if="row.orderId" type="link" @click="handleViewOrder(row)">{{ row.orderNo }}</Button>
      <span v-else>—</span>
    </template>
    <template #actions="{ row }">
      <TableAction
@@ -114,6 +136,8 @@
            label: '查看审批',
            type: 'link',
            icon: ACTION_ICON.VIEW,
            auth: ['crm:contract:update'],
            ifShow: !!row.processInstanceId,
            onClick: handleProcessDetail.bind(null, row),
          },
        ]"
src/views/crm/backlog/modules/contract-remind-list.vue
@@ -5,7 +5,7 @@
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { Button, Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
@@ -30,17 +30,21 @@
/** æ‰“开客户详情 */
function handleCustomerDetail(row: CrmContractApi.Contract) {
  push({ name: 'CrmCustomerDetail', params: { id: row.id } });
  if (row.customerId) push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** æ‰“开联系人详情 */
function handleContactDetail(row: CrmContractApi.Contract) {
  push({ name: 'CrmContactDetail', params: { id: row.id } });
  if (row.signContactId) push({ name: 'CrmContactDetail', params: { id: row.signContactId } });
}
/** æ‰“开商机详情 */
function handleBusinessDetail(row: CrmContractApi.Contract) {
  push({ name: 'CrmBusinessDetail', params: { id: row.id } });
  if (row.businessId) push({ name: 'CrmBusinessDetail', params: { id: row.businessId } });
}
function handleViewOrder(row: CrmContractApi.Contract) {
  if (row.orderId) push({ name: 'ErpSaleOrder', query: { id: row.orderId } });
}
const [Grid] = useVbenVxeGrid({
@@ -88,6 +92,11 @@
<template>
  <Grid>
    <template #no="{ row }">
      <Button type="link" @click="handleContractDetail(row)">
        {{ row.no }}
      </Button>
    </template>
    <template #name="{ row }">
      <Button type="link" @click="handleContractDetail(row)">
        {{ row.name }}
@@ -108,6 +117,19 @@
        {{ row.signContactName }}
      </Button>
    </template>
    <template #auditStatus="{ row }">
      <Tag v-if="row.auditStatus === 0">未提交</Tag>
      <Tag v-else-if="row.auditStatus === 10" color="warning">审批中</Tag>
      <Tag v-else-if="row.auditStatus === 20" color="success">审核通过</Tag>
      <Tag v-else-if="row.auditStatus === 30" color="error">审核不通过</Tag>
      <Tag v-else-if="row.auditStatus === 40">已取消</Tag>
      <Tag v-else-if="row.auditStatus === 50" color="error">已作废</Tag>
      <span v-else>—</span>
    </template>
    <template #orderNo="{ row }">
      <Button v-if="row.orderId" type="link" @click="handleViewOrder(row)">{{ row.orderNo }}</Button>
      <span v-else>—</span>
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
@@ -115,6 +137,7 @@
            label: '查看审批',
            type: 'link',
            auth: ['crm:contract:update'],
            ifShow: !!row.processInstanceId,
            onClick: handleProcessDetail.bind(null, row),
          },
        ]"
src/views/crm/backlog/modules/customer-follow-list.vue
@@ -4,6 +4,7 @@
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { erpNumberFormatter } from '@vben/utils';
import { Button } from 'ant-design-vue';
@@ -68,6 +69,10 @@
    <template #name="{ row }">
      <Button type="link" @click="handleDetail(row)">{{ row.name }}</Button>
    </template>
    <template #contracts="{ row }">
      <span v-if="row.contractCount">{{ row.contractCount }} ä»½ Â· Â¥{{ erpNumberFormatter(row.contractTotalPrice, 2) }}</span>
      <span v-else>—</span>
    </template>
    <template #actions="{ row }">
      <Button type="link" @click="handleDetail(row)">查看详情</Button>
    </template>
src/views/crm/backlog/modules/customer-put-pool-remind-list.vue
@@ -4,11 +4,12 @@
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { erpNumberFormatter } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { getPutPoolRemindCustomerPage } from '#/api/crm/customer';
import { useGridColumns } from '#/views/crm/customer/data';
import { SCENE_TYPES } from '../data';
@@ -42,10 +43,9 @@
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getCustomerPage({
          return await getPutPoolRemindCustomerPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            pool: true, // å›ºå®š å…¬æµ·å‚数为 true
            ...formValues,
          });
        },
@@ -68,6 +68,10 @@
    <template #name="{ row }">
      <Button type="link" @click="handleDetail(row)">{{ row.name }}</Button>
    </template>
    <template #contracts="{ row }">
      <span v-if="row.contractCount">{{ row.contractCount }} ä»½ Â· Â¥{{ erpNumberFormatter(row.contractTotalPrice, 2) }}</span>
      <span v-else>—</span>
    </template>
    <template #actions="{ row }">
      <Button type="link" @click="handleDetail(row)">查看详情</Button>
    </template>
src/views/crm/backlog/modules/customer-today-contact-list.vue
@@ -4,6 +4,7 @@
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { erpNumberFormatter } from '@vben/utils';
import { Button } from 'ant-design-vue';
@@ -78,6 +79,10 @@
    <template #name="{ row }">
      <Button type="link" @click="handleDetail(row)">{{ row.name }}</Button>
    </template>
    <template #contracts="{ row }">
      <span v-if="row.contractCount">{{ row.contractCount }} ä»½ Â· Â¥{{ erpNumberFormatter(row.contractTotalPrice, 2) }}</span>
      <span v-else>—</span>
    </template>
    <template #actions="{ row }">
      <Button type="link" @click="handleDetail(row)">查看详情</Button>
    </template>
src/views/crm/backlog/modules/receivable-audit-list.vue
@@ -7,13 +7,14 @@
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage, updateReceivableStatus } from '#/api/crm/receivable';
import { useGridColumns } from '#/views/crm/receivable/data';
import { AUDIT_STATUS } from '../data';
const { push } = useRouter();
const emit = defineEmits<{ refresh: [] }>();
/** å®¡æ ¸ */
async function handleAudit(row: CrmReceivableApi.Receivable) {
@@ -24,7 +25,8 @@
  try {
    await updateReceivableStatus(row.id!, 20);
    message.success('审核成功');
    gridApi.query();
    await gridApi.query();
    emit('refresh');
  } finally {
    hideLoading();
  }
@@ -42,7 +44,11 @@
/** æ‰“开合同详情 */
function handleContractDetail(row: CrmReceivableApi.Receivable) {
  push({ name: 'CrmContractDetail', params: { id: row.contractId } });
  if (row.contractId) push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
function handleInvoiceDetail(row: CrmReceivableApi.Receivable) {
  if (row.invoiceId) push({ name: 'CrmInvoiceDetail', params: { id: row.invoiceId } });
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -50,7 +56,7 @@
    schema: [
      {
        fieldName: 'auditStatus',
        label: '合同状态',
        label: '审核状态',
        component: 'Select',
        componentProps: {
          allowClear: true,
@@ -70,6 +76,7 @@
          return await getReceivablePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            sceneType: 1,
            ...formValues,
          });
        },
@@ -100,18 +107,26 @@
      </Button>
    </template>
    <template #contractNo="{ row }">
      <Button type="link" @click="handleContractDetail(row)">
        {{ row.contractNo }}
      <Button v-if="row.contractId" type="link" @click="handleContractDetail(row)">
        {{ row.contract?.no ?? row.contractNo ?? '—' }}
      </Button>
      <span v-else>—</span>
    </template>
    <template #invoiceNo="{ row }">
      <template v-if="row.invoice">
        <Button type="link" @click="handleInvoiceDetail(row)">{{ row.invoice.no }}</Button>
        <span> / {{ row.invoice.invoiceNo ?? '—' }} / Â¥{{ row.invoice.price ?? 0 }}</span>
      </template>
      <span v-else>—</span>
    </template>
    <template #actions="{ row }">
      <Button
        v-if="row.auditStatus === 10"
        type="link"
        @click="handleAudit(row)"
      >
        å®¡æ ¸
      </Button>
      <TableAction :actions="[{
        label: '审核',
        type: 'link',
        auth: ['crm:receivable:update'],
        ifShow: row.auditStatus === 10,
        onClick: handleAudit.bind(null, row),
      }]" />
    </template>
  </Grid>
</template>
src/views/crm/backlog/modules/receivable-plan-remind-list.vue
@@ -9,7 +9,7 @@
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePlanPage } from '#/api/crm/receivable/plan';
import Form from '#/views/crm/receivable/modules/form.vue';
import { useGridColumns } from '#/views/crm/receivable/plan/data';
@@ -17,15 +17,21 @@
import { RECEIVABLE_REMIND_TYPE } from '../data';
const { push } = useRouter();
const emit = defineEmits<{ refresh: [] }>();
function handleRefresh() {
  gridApi.query();
  emit('refresh');
}
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** æ‰“开回款详情 */
/** æ‰“开回款计划详情 */
function handleDetail(row: CrmReceivablePlanApi.Plan) {
  push({ name: 'CrmReceivableDetail', params: { id: row.id } });
  push({ name: 'CrmReceivablePlanDetail', params: { id: row.id } });
}
/** æ‰“开客户详情 */
@@ -35,15 +41,16 @@
/** åˆ›å»ºå›žæ¬¾ */
function handleCreateReceivable(row: CrmReceivablePlanApi.Plan) {
  if (row.receivableId) return;
  formModalApi.setData({ plan: row }).open();
}
const [Grid] = useVbenVxeGrid({
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'remindType',
        label: '合同状态',
        label: '回款状态',
        component: 'Select',
        componentProps: {
          allowClear: true,
@@ -63,6 +70,7 @@
          return await getReceivablePlanPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            sceneType: 1,
            ...formValues,
          });
        },
@@ -82,7 +90,7 @@
<template>
  <div>
    <FormModal />
    <FormModal @success="handleRefresh" />
    <Grid>
      <template #customerName="{ row }">
        <Button type="link" @click="handleCustomerDetail(row)">
@@ -93,9 +101,13 @@
        <Button type="link" @click="handleDetail(row)">{{ row.period }}</Button>
      </template>
      <template #actions="{ row }">
        <Button type="link" @click="handleCreateReceivable(row)">
          åˆ›å»ºå›žæ¬¾
        </Button>
        <TableAction :actions="[{
          label: '创建回款',
          type: 'link',
          auth: ['crm:receivable:create'],
          ifShow: !row.receivableId,
          onClick: handleCreateReceivable.bind(null, row),
        }]" />
      </template>
    </Grid>
  </div>
src/views/crm/business/data.ts
@@ -97,7 +97,7 @@
      component: 'DatePicker',
      componentProps: {
        showTime: false,
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择预计成交日期',
      },
@@ -218,7 +218,7 @@
    {
      field: 'contactNextTime',
      title: '下次联系时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      width: 180,
    },
    {
@@ -234,19 +234,19 @@
    {
      field: 'contactLastTime',
      title: '最后跟进时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      width: 180,
    },
    {
      field: 'updateTime',
      title: '更新时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      width: 180,
    },
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      width: 180,
    },
    {
src/views/crm/business/detail/data.ts
@@ -4,7 +4,7 @@
import type { CrmBusinessApi } from '#/api/crm/business';
import type { DescriptionItemSchema } from '#/components/description';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import {
  getBusinessStatusSimpleList,
@@ -33,7 +33,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
@@ -57,12 +57,12 @@
    {
      field: 'dealTime',
      label: '预计成交日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'contactNextTime',
      label: '下次联系时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'statusTypeName',
src/views/crm/business/detail/index.vue
@@ -168,7 +168,7 @@
          />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="6" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="团队成员" key="7" :force-render="true">
          <PermissionList
src/views/crm/business/status/data.ts
@@ -70,7 +70,7 @@
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/crm/clue/data.ts
@@ -143,7 +143,7 @@
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择下次联系时间',
      },
@@ -209,6 +209,7 @@
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        format: 'YYYY-MM-DD',
        allowClear: true,
        placeholder: ['开始日期', '结束日期'],
      },
@@ -278,7 +279,7 @@
    {
      field: 'contactNextTime',
      title: '下次联系时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
@@ -289,7 +290,7 @@
    {
      field: 'contactLastTime',
      title: '最后跟进时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
@@ -310,13 +311,13 @@
    {
      field: 'updateTime',
      title: '更新时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
src/views/crm/clue/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
@@ -30,7 +30,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
@@ -101,7 +101,7 @@
    {
      field: 'contactNextTime',
      label: '下次联系时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'remark',
src/views/crm/clue/detail/index.vue
@@ -162,7 +162,7 @@
          />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="4" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
      </Tabs>
    </Card>
src/views/crm/contact/data.ts
@@ -164,7 +164,7 @@
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择下次联系时间',
      },
@@ -304,7 +304,7 @@
    {
      field: 'contactNextTime',
      title: '下次联系时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
@@ -324,7 +324,7 @@
    {
      field: 'contactLastTime',
      title: '最后跟进时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
@@ -340,13 +340,13 @@
    {
      field: 'updateTime',
      title: '更新时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
src/views/crm/contact/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
@@ -25,7 +25,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
@@ -96,7 +96,7 @@
    {
      field: 'contactNextTime',
      label: '下次联系时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'remark',
src/views/crm/contact/detail/index.vue
@@ -134,7 +134,7 @@
          <Info :contact="contact" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="3" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="团队成员" key="4" :force-render="true">
          <PermissionList
src/views/crm/contract/components/data.ts
@@ -28,13 +28,13 @@
      title: '合同开始时间',
      field: 'startTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '合同结束时间',
      field: 'endTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '已回款金额(元)',
@@ -78,7 +78,7 @@
      title: '创建时间',
      field: 'createTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
src/views/crm/contract/data.ts
@@ -326,6 +326,7 @@
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'checkbox', width: 50, fixed: 'left' },
    {
      title: '合同编号',
      field: 'no',
@@ -368,19 +369,19 @@
      title: '下单时间',
      field: 'orderDate',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '合同开始时间',
      field: 'startTime',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '合同结束时间',
      field: 'endTime',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '客户签约人',
@@ -430,7 +431,7 @@
      title: '最后跟进时间',
      field: 'contactLastTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '负责人',
@@ -446,13 +447,13 @@
      title: '更新时间',
      field: 'updateTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建时间',
      field: 'createTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
src/views/crm/contract/detail/approve/index.vue
@@ -10,7 +10,7 @@
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
  formatDate,
} from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -116,17 +116,17 @@
    {
      label: '下单时间',
      field: 'orderDate',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合同开始时间',
      field: 'startTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合同结束时间',
      field: 'endTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '客户签约人', field: 'signContactName', render: textRender },
    { label: '公司签约人', field: 'signUserName', render: textRender },
@@ -135,7 +135,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合同状态',
src/views/crm/contract/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
@@ -27,7 +27,7 @@
    {
      field: 'orderDate',
      label: '下单时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'totalReceivablePrice',
@@ -88,17 +88,17 @@
    {
      field: 'orderDate',
      label: '下单时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'startTime',
      label: '合同开始时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'endTime',
      label: '合同结束时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'signContactName',
src/views/crm/contract/detail/index.vue
@@ -17,6 +17,7 @@
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { downloadContractWord } from '#/utils/contract-document';
import { FollowUp } from '#/views/crm/followup';
import { PermissionList, TransferForm } from '#/views/crm/permission';
import { ProductDetailsList } from '#/views/crm/product/components';
@@ -119,6 +120,13 @@
            onClick: handleEdit,
          },
          {
            label: '下载合同',
            type: 'primary',
            icon: 'lucide:download',
            auth: ['crm:contract:query'],
            onClick: () => downloadContractWord(contractId, contract.no),
          },
          {
            label: '转移',
            type: 'primary',
            ifShow: permissionListRef?.validateOwnerUser,
@@ -169,7 +177,7 @@
          />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="6" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
      </Tabs>
    </Card>
src/views/crm/contract/index.vue
@@ -20,6 +20,8 @@
  submitContract,
} from '#/api/crm/contract';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { downloadContractWord } from '#/utils/contract-document';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -40,6 +42,7 @@
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.grid.clearCheckboxRow();
  gridApi.query();
}
@@ -54,6 +57,24 @@
  const formValues = await gridApi.formApi.getValues();
  const data = await exportContract({
    sceneType: sceneType.value,
    ...formValues,
  });
  downloadFileFromBlobPart({ fileName: '合同.xls', source: data });
}
/** å¯¼å‡ºå‹¾é€‰çš„合同 */
async function handleExportSelected() {
  const rows: CrmContractApi.Contract[] =
    gridApi.grid.getCheckboxRecords?.() ?? [];
  const ids = rows.map((row) => row.id!);
  if (ids.length === 0) {
    message.warning('请先勾选要导出的合同');
    return;
  }
  const formValues = await gridApi.formApi.getValues();
  const data = await exportContract({
    sceneType: sceneType.value,
    ids: ids.join(','),
    ...formValues,
  });
  downloadFileFromBlobPart({ fileName: '合同.xls', source: data });
@@ -97,10 +118,15 @@
const submitLoading = ref(false);
async function handleSubmit(row: CrmContractApi.Contract) {
  const res = await getContractApproveProcessList();
  const res = automaticApproval.value ? [] : await getContractApproveProcessList();
  processList.value = res;
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'contract_approve';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
@@ -112,7 +138,7 @@
  submitLoading.value = true;
  try {
    await submitContract(currentRow.value!.id!, selectedProcessKey.value);
    message.success('提交审批成功');
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
@@ -243,6 +269,13 @@
              onClick: handleExport,
            },
            {
              label: '导出选中',
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['crm:contract:export'],
              onClick: handleExportSelected,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
@@ -300,6 +333,13 @@
        <TableAction
          :actions="[
            {
              label: '下载合同',
              type: 'link',
              icon: 'lucide:download',
              auth: ['crm:contract:query'],
              onClick: () => downloadContractWord(row.id, row.no),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/crm/customer/data.ts
@@ -153,11 +153,22 @@
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择下次联系时间',
        allowClear: true,
      },
    },
    {
      fieldName: 'autoPoolEnabled',
      label: '是否流入公海',
      component: 'Switch',
      defaultValue: true,
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      help: '关闭后不自动流入公海,并取消普通用户的公海可见度;保留负责人和团队权限,超级管理员仍可查看。',
    },
    {
      fieldName: 'remark',
@@ -202,11 +213,25 @@
      },
    },
    {
      fieldName: 'autoPoolEnabled',
      label: '是否流入公海',
      component: 'Select',
      componentProps: {
        options: [
          { label: '是', value: true },
          { label: '否', value: false },
        ],
        placeholder: '请选择',
        allowClear: true,
      },
    },
    {
      fieldName: 'createTime',
      label: '创建时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        format: 'YYYY-MM-DD',
        placeholder: ['开始日期', '结束日期'],
        allowClear: true,
      },
@@ -240,7 +265,7 @@
      label: '客户数据',
      component: 'Upload',
      rules: 'required',
      help: '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶',
      help: '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ã€‚“是否流入公海”列填写是/否;留空时新增默认是,覆盖保留原值。关闭后取消公海可见度。',
    },
    {
      fieldName: 'updateSupport',
@@ -276,21 +301,6 @@
      },
    },
    {
      field: 'mobile',
      title: '手机',
      minWidth: 120,
    },
    {
      field: 'telephone',
      title: '电话',
      minWidth: 130,
    },
    {
      field: 'email',
      title: '邮箱',
      minWidth: 180,
    },
    {
      field: 'level',
      title: '客户级别',
      minWidth: 135,
@@ -311,7 +321,7 @@
    {
      field: 'contactNextTime',
      title: '下次联系时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
@@ -329,18 +339,24 @@
      },
    },
    {
      field: 'dealStatus',
      title: '成交状态',
      minWidth: 120,
      field: 'autoPoolEnabled',
      title: '是否流入公海',
      minWidth: 140,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    {
      field: 'contractCount',
      title: '签订合同',
      minWidth: 170,
      slots: { default: 'contracts' },
    },
    {
      field: 'contactLastTime',
      title: '最后跟进时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
@@ -352,13 +368,6 @@
      field: 'detailAddress',
      title: '地址',
      minWidth: 180,
    },
    {
      field: 'poolDay',
      title: '距离进入公海天数',
      minWidth: 140,
      formatter: ({ cellValue }) =>
        cellValue === null ? '-' : `${cellValue} å¤©`,
    },
    {
      field: 'ownerUserName',
@@ -373,13 +382,13 @@
    {
      field: 'updateTime',
      title: '更新时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
src/views/crm/customer/detail/data.ts
@@ -5,7 +5,7 @@
import { DICT_TYPE } from '@vben/constants';
import { useUserStore } from '@vben/stores';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
@@ -47,9 +47,9 @@
        h(DictTag, { type: DICT_TYPE.CRM_CUSTOMER_LEVEL, value: val }),
    },
    {
      field: 'dealStatus',
      label: '成交状态',
      render: (val) => (val ? '已成交' : '未成交'),
      field: 'autoPoolEnabled',
      label: '是否流入公海',
      render: (val) => (val === false ? '否' : '是'),
    },
    {
      field: 'ownerUserName',
@@ -58,7 +58,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
@@ -120,7 +120,7 @@
    {
      field: 'contactNextTime',
      label: '下次联系时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'remark',
src/views/crm/customer/detail/index.vue
@@ -15,7 +15,6 @@
  lockCustomer,
  putCustomerPool,
  receiveCustomer,
  updateCustomerDealStatus,
} from '#/api/crm/customer';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
@@ -148,23 +147,6 @@
//   return true;
// }
/** æ›´æ–°æˆäº¤çŠ¶æ€æ“ä½œ */
async function handleUpdateDealStatus(): Promise<boolean | undefined> {
  const dealStatus = !customer.value.dealStatus;
  try {
    await confirm(
      `确定更新成交状态为【${dealStatus ? '已成交' : '未成交'}】吗?`,
    );
  } catch {
    return false;
  }
  // æ›´æ–°æˆäº¤çŠ¶æ€
  await updateCustomerDealStatus(customerId.value, dealStatus);
  // æç¤ºå¹¶è¿”回成功
  message.success('更新成交状态成功');
  return true;
}
/** åŠ è½½æ•°æ® */
onMounted(() => {
  customerId.value = Number(route.params.id);
@@ -199,12 +181,6 @@
            type: 'primary',
            ifShow: permissionListRef?.validateOwnerUser,
            onClick: handleTransfer,
          },
          {
            label: '更改成交状态',
            type: 'default',
            ifShow: permissionListRef?.validateWrite,
            onClick: handleUpdateDealStatus,
          },
          {
            label: '锁定',
@@ -286,7 +262,7 @@
          <ReceivableDetailsList :customer-id="customerId" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="8" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
      </Tabs>
    </Card>
src/views/crm/customer/detail/modules/distribute-form.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
@@ -10,6 +12,7 @@
import { useDistributeFormSchema } from '../data';
const emit = defineEmits(['success']);
const customerIds = ref<number[]>([]);
const [Form, formApi] = useVbenForm({
  commonConfig: {
@@ -34,7 +37,7 @@
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      await distributeCustomer([data.id], data.ownerUserId);
      await distributeCustomer(customerIds.value, data.ownerUserId);
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
@@ -48,13 +51,15 @@
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData();
    if (!data || !data.id) {
    const data = modalApi.getData<{ id?: number; ids?: number[] }>();
    customerIds.value = data?.ids ?? (data?.id ? [data.id] : []);
    if (customerIds.value.length === 0) {
      return;
    }
    modalApi.lock();
    try {
      await formApi.setValues(data);
      await formApi.resetForm();
      await formApi.setValues({ id: customerIds.value[0] });
    } finally {
      modalApi.unlock();
    }
src/views/crm/customer/index.vue
@@ -6,7 +6,7 @@
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { downloadFileFromBlobPart, erpNumberFormatter } from '@vben/utils';
import { Button, message, Tabs } from 'ant-design-vue';
@@ -166,6 +166,12 @@
          {{ row.name }}
        </Button>
      </template>
      <template #contracts="{ row }">
        <template v-if="row.contractCount">
          {{ row.contractCount }} ä»½ Â· Â¥{{ erpNumberFormatter(row.contractTotalPrice, 2) }}
        </template>
        <template v-else>—</template>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
src/views/crm/customer/limitConfig/data.ts
@@ -143,7 +143,7 @@
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/crm/customer/modules/import-form.vue
@@ -4,11 +4,11 @@
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Upload } from 'ant-design-vue';
import { Button, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importCustomer, importCustomerTemplate } from '#/api/crm/customer';
import { $t } from '#/locales';
import { notifyImportResult } from '#/utils';
import { useImportFormSchema } from '../data';
@@ -34,15 +34,25 @@
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      await importCustomer({
      const importData = await importCustomer({
        ownerUserId: data.ownerUserId,
        file: data.file,
        updateSupport: data.updateSupport,
      });
      // å…³é—­å¹¶æç¤º
      const createCount = importData.createCustomerNames?.length || 0;
      const updateCount = importData.updateCustomerNames?.length || 0;
      // å…³é—­å¹¶æŒ‰å¯¼å…¥ç»“果提示:仅在确有数据写入时通知列表刷新
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
      if (createCount + updateCount > 0) {
        emit('success');
      }
      notifyImportResult({
        subject: '客户',
        createCount,
        updateCount,
        failures: importData.failureCustomerNames,
      });
    } finally {
      modalApi.unlock();
    }
src/views/crm/customer/pool/data.ts
@@ -60,6 +60,7 @@
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'checkbox', width: 50, fixed: 'left' },
    {
      title: '客户名称',
      field: 'name',
@@ -113,7 +114,7 @@
      title: '下次联系时间',
      field: 'contactNextTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '备注',
@@ -121,19 +122,10 @@
      minWidth: 200,
    },
    {
      title: '成交状态',
      field: 'dealStatus',
      minWidth: 80,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    {
      title: '最后跟进时间',
      field: 'contactLastTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '最后跟进记录',
@@ -141,21 +133,37 @@
      minWidth: 200,
    },
    {
      title: '负责人',
      field: 'ownerUserName',
      minWidth: 100,
    },
    {
      title: '所属部门',
      field: 'ownerUserDeptName',
      minWidth: 100,
    },
    {
      title: '更新时间',
      field: 'updateTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建时间',
      field: 'createTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
      field: 'creatorName',
      minWidth: 100,
    },
    {
      title: '操作',
      width: 150,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/crm/customer/pool/index.vue
@@ -2,24 +2,94 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportCustomer, getCustomerPage } from '#/api/crm/customer';
import {
  exportCustomer,
  getCustomerPage,
  receiveCustomer,
} from '#/api/crm/customer';
import { $t } from '#/locales';
import DistributeForm from '../detail/modules/distribute-form.vue';
import { useGridColumns, useGridFormSchema } from './data';
const { push } = useRouter();
const receiving = ref(false);
const [DistributeModal, distributeModalApi] = useVbenModal({
  connectedComponent: DistributeForm,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.grid.clearCheckboxRow();
  gridApi.query();
}
function getCustomerIds(row?: CrmCustomerApi.Customer): number[] {
  const records: CrmCustomerApi.Customer[] = row
    ? [row]
    : gridApi.grid.getCheckboxRecords();
  if (
    records.some(
      (item) => item.ownerUserId != null || item.autoPoolEnabled === false,
    )
  ) {
    message.warning('仅可领取或分配无负责人且允许流入公海的客户');
    return [];
  }
  const ids = records.map((item) => item.id!);
  if (ids.length === 0) {
    message.warning('请先勾选客户');
  }
  return ids;
}
/** é¢†å–单条或勾选的公海客户 */
async function handleReceive(row?: CrmCustomerApi.Customer) {
  if (receiving.value) return;
  const ids = getCustomerIds(row);
  if (ids.length === 0) return;
  receiving.value = true;
  try {
    try {
      await confirm(
        row
          ? `确定领取客户【${row.name}】吗?`
          : `确定领取选中的 ${ids.length} ä¸ªå®¢æˆ·å—?`,
      );
    } catch {
      return;
    }
    await receiveCustomer(ids);
    message.success('领取客户成功');
    handleRefresh();
  } finally {
    receiving.value = false;
  }
}
/** åˆ†é…å•条或勾选的公海客户 */
function handleDistribute(row?: CrmCustomerApi.Customer) {
  const ids = getCustomerIds(row);
  if (ids.length === 0) return;
  distributeModalApi.setData({ ids }).open();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportCustomer(await gridApi.formApi.getValues());
  const data = await exportCustomer({
    ...(await gridApi.formApi.getValues()),
    pool: true,
    sceneType: 4,
  });
  downloadFileFromBlobPart({ fileName: '客户公海.xls', source: data });
}
@@ -36,6 +106,10 @@
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    checkboxConfig: {
      checkMethod: ({ row }: { row: CrmCustomerApi.Customer }) =>
        row.ownerUserId == null && row.autoPoolEnabled !== false,
    },
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
@@ -62,10 +136,25 @@
</script>
<template>
  <Page auto-content-height><Grid>
  <Page auto-content-height>
    <DistributeModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '领取客户',
              type: 'primary',
              auth: ['crm:customer:receive'],
              disabled: receiving,
              onClick: () => handleReceive(),
            },
            {
              label: '分配客户',
              auth: ['crm:customer:distribute'],
              disabled: receiving,
              onClick: () => handleDistribute(),
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
@@ -76,6 +165,28 @@
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '领取',
              type: 'link',
              auth: ['crm:customer:receive'],
              disabled: receiving,
              ifShow: row.ownerUserId == null && row.autoPoolEnabled !== false,
              onClick: () => handleReceive(row),
            },
            {
              label: '分配',
              type: 'link',
              auth: ['crm:customer:distribute'],
              disabled: receiving,
              ifShow: row.ownerUserId == null && row.autoPoolEnabled !== false,
              onClick: () => handleDistribute(row),
            },
          ]"
        />
      </template>
      <template #name="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.name }}
src/views/crm/followup/data.ts
@@ -6,7 +6,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { getBusinessPageByCustomer } from '#/api/crm/business';
import { getContactPageByCustomer } from '#/api/crm/contact';
@@ -48,7 +48,7 @@
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择下次联系时间',
      },
@@ -128,7 +128,7 @@
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    { field: 'creatorName', title: '跟进人' },
    {
@@ -143,7 +143,7 @@
    {
      field: 'nextTime',
      title: '下次联系时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'contacts',
@@ -179,7 +179,7 @@
    {
      field: 'contactLastTime',
      label: '最后跟进时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'creatorName',
@@ -188,12 +188,12 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
src/views/crm/invoice/data.ts
@@ -194,6 +194,15 @@
      },
    },
    {
      fieldName: 'invoiceNo',
      label: '发票号码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入发票号码,支持模糊查询',
        allowClear: true,
      },
    },
    {
      fieldName: 'customerId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
@@ -256,7 +265,7 @@
      title: '开票日期',
      field: 'invoiceTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '备注',
@@ -277,13 +286,13 @@
      title: '更新时间',
      field: 'updateTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建时间',
      field: 'createTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
src/views/crm/invoice/detail/approve/index.vue
@@ -6,7 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -58,7 +58,7 @@
    {
      label: '开票日期',
      field: 'invoiceTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '审批状态',
@@ -71,7 +71,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/crm/invoice/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
@@ -27,7 +27,7 @@
    {
      field: 'invoiceTime',
      label: '开票日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'invoiceType',
@@ -100,7 +100,7 @@
    {
      field: 'invoiceTime',
      label: '开票日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'remark',
@@ -123,12 +123,12 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
src/views/crm/invoice/detail/index.vue
@@ -17,6 +17,7 @@
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { PermissionList } from '#/views/crm/permission';
import InvoiceForm from '../modules/form.vue';
@@ -96,7 +97,7 @@
            type: 'primary',
            icon: ACTION_ICON.EDIT,
            auth: ['crm:invoice:update'],
            ifShow: permissionListRef?.validateWrite,
            ifShow: permissionListRef?.validateWrite && (automaticApproval || invoice.auditStatus === 0 || invoice.auditStatus === 10),
            onClick: handleEdit,
          },
        ]"
@@ -111,7 +112,7 @@
          <Info :invoice="invoice" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="2" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="团队成员" key="3" :force-render="true">
          <PermissionList
src/views/crm/invoice/index.vue
@@ -18,6 +18,7 @@
  submitInvoice,
} from '#/api/crm/invoice';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -90,12 +91,12 @@
/** æäº¤å®¡æ ¸ */
async function handleSubmit(row: CrmInvoiceApi.Invoice) {
  const hideLoading = message.loading({
    content: '提交审核中...',
    content: automaticApproval.value ? '正在提交生效...' : '提交审核中...',
    duration: 0,
  });
  try {
    await submitInvoice(row.id!);
    message.success('提交审核成功');
    message.success(automaticApproval.value ? '已提交生效' : '提交审核成功');
    handleRefresh();
  } finally {
    hideLoading();
@@ -244,6 +245,7 @@
              icon: ACTION_ICON.EDIT,
              auth: ['crm:invoice:update'],
              onClick: handleEdit.bind(null, row),
              ifShow: automaticApproval || row.auditStatus === 0 || row.auditStatus === 10,
            },
            {
              label: '提交审核',
src/views/crm/permission/modules/data.ts
@@ -227,7 +227,7 @@
    {
      field: 'createTime',
      title: '加入时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
  ];
}
src/views/crm/product/category/data.ts
@@ -80,7 +80,7 @@
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'actions',
src/views/crm/product/data.ts
@@ -208,7 +208,7 @@
    {
      field: 'updateTime',
      title: '更新时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
@@ -219,7 +219,7 @@
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
      minWidth: 180,
    },
    {
src/views/crm/product/detail/index.vue
@@ -80,7 +80,7 @@
          <Info :product="product" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="2" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
      </Tabs>
    </Card>
src/views/crm/receivable/components/data.ts
@@ -25,7 +25,7 @@
      title: '回款日期',
      field: 'returnTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '回款金额(元)',
src/views/crm/receivable/data.ts
@@ -303,7 +303,7 @@
      title: '回款日期',
      field: 'returnTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '回款金额(元)',
@@ -345,13 +345,13 @@
      title: '更新时间',
      field: 'updateTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建时间',
      field: 'createTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
src/views/crm/receivable/detail/approve/index.vue
@@ -6,7 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -51,7 +51,7 @@
    {
      label: '回款日期',
      field: 'returnTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '回款金额(元)',
@@ -78,7 +78,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/crm/receivable/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
@@ -23,7 +23,7 @@
    {
      field: 'returnTime',
      label: '回款日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'price',
@@ -65,7 +65,7 @@
    {
      field: 'returnTime',
      label: '回款日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'price',
@@ -102,12 +102,12 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
src/views/crm/receivable/detail/index.vue
@@ -114,7 +114,7 @@
          <Info :receivable="receivable" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="2" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="团队成员" key="3" :force-render="true">
          <PermissionList
src/views/crm/receivable/plan/components/data.ts
@@ -28,7 +28,7 @@
      title: '计划回款日期',
      field: 'returnTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '提前几天提醒',
@@ -39,7 +39,7 @@
      title: '提醒日期',
      field: 'remindTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '负责人',
src/views/crm/receivable/plan/data.ts
@@ -201,7 +201,7 @@
      title: '计划回款日期',
      field: 'returnTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '提前几天提醒',
@@ -212,7 +212,7 @@
      title: '提醒日期',
      field: 'remindTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '回款方式',
@@ -243,7 +243,7 @@
      title: '实际回款日期',
      field: 'receivable.returnTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '未回款金额(元)',
@@ -260,13 +260,13 @@
      title: '更新时间',
      field: 'updateTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建时间',
      field: 'createTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
src/views/crm/receivable/plan/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
@@ -26,7 +26,7 @@
    {
      field: 'returnTime',
      label: '计划回款日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'receivable',
@@ -54,7 +54,7 @@
    {
      field: 'returnTime',
      label: '计划回款日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'price',
@@ -90,7 +90,7 @@
    {
      field: 'receivable.returnTime',
      label: '实际回款日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'remark',
@@ -113,12 +113,12 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
src/views/crm/receivable/plan/detail/index.vue
@@ -117,7 +117,7 @@
          <ReceivablePlanDetailsInfo :receivable-plan="receivablePlan" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="2" :force-render="true">
          <OperateLog :log-list="logList" />
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="团队成员" key="3" :force-render="true">
          <PermissionList
src/views/crm/saleQuotation/data.ts
@@ -302,13 +302,13 @@
      field: 'quotationTime',
      title: '报价日期',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'validUntil',
      title: '有效期至',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'status',
@@ -330,7 +330,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/crm/saleQuotation/index.vue
@@ -15,10 +15,13 @@
  getSaleQuotationPage,
} from '#/api/crm/saleQuotation';
import { $t } from '#/locales';
import { downloadContractWord } from '#/utils/contract-document';
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { isAiEnable } from '@vben/hooks';
import { QUOTATION_STATUS, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -107,10 +110,37 @@
    async onOk() {
      const contractId = await convertSaleQuotationToContract(row.id!);
      message.success('转合同成功');
      gridApi.query();
      try {
        await downloadContractWord(contractId);
      } catch {
        message.warning('合同已创建,文件下载失败,可在合同详情中重新下载');
      }
      push({ name: 'CrmContractDetail', params: { id: contractId } });
    },
  });
}
/** å·¥å…·æ æŒ‰é’®ï¼šè¯†åˆ«å½•入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['报价单']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['crm:sale-quotation:create'],
    onClick: handleCreate,
  },
  ...(isAiEnable()
    ? [
        {
          label: '识别录入',
          type: 'default' as const,
          auth: ['crm:sale-quotation:create'],
          onClick: handleOcrCreate,
        },
      ]
    : []),
]);
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
@@ -149,23 +179,7 @@
    <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['报价单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['crm:sale-quotation:create'],
              onClick: handleCreate,
            },
            {
              label: '识别录入',
              type: 'default',
              auth: ['crm:sale-quotation:create'],
              onClick: handleOcrCreate,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #name="{ row }">
        <Button type="link" @click="handleDetail(row)">
@@ -218,6 +232,13 @@
              ifShow: !!row.contractId,
            },
            {
              label: '下载合同',
              type: 'link',
              auth: ['crm:contract:query'],
              onClick: () => downloadContractWord(row.contractId!, row.contractNo),
              ifShow: !!row.contractId,
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
@@ -233,4 +254,4 @@
      </template>
    </Grid>
  </Page>
</template>
</template>
src/views/crm/saleQuotation/modules/ocr-upload.vue
@@ -4,6 +4,7 @@
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { Modal, Button, Upload, Descriptions, Table, Tag, message, Alert } from 'ant-design-vue';
@@ -140,7 +141,7 @@
            <Tag color="orange">{{ ocrResult.customerName }}</Tag>
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.quotationTime" label="报价日期">
            {{ ocrResult.quotationTime }}
            {{ formatDate(ocrResult.quotationTime) }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.validUntil" label="有效期至">
            {{ ocrResult.validUntil }}
src/views/crm/salesTarget/analysis/data.ts
@@ -4,6 +4,25 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { SALES_TARGET_PERIOD_TYPE } from '../data';
/**
 * æŒ‰å‘¨æœŸç±»åž‹ç®—当前周期值:年度 2026 / å­£åº¦ 2026-Q4 / æœˆåº¦ 2026-10。
 * è¾¾æˆåˆ†æžæ‰“开时默认落在「当前季度」,与销售目标的演示数据周期一致。
 */
function currentPeriodValue(periodType?: number): string {
  const now = new Date();
  const year = now.getFullYear();
  const month = now.getMonth() + 1;
  if (periodType === SALES_TARGET_PERIOD_TYPE.YEAR) {
    return `${year}`;
  }
  if (periodType === SALES_TARGET_PERIOD_TYPE.MONTH) {
    return `${year}-${String(month).padStart(2, '0')}`;
  }
  return `${year}-Q${Math.ceil(month / 3)}`;
}
/** è¾¾æˆåˆ†æžçš„æŸ¥è¯¢è¡¨å• */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
@@ -12,11 +31,18 @@
      label: '周期类型',
      component: 'Select',
      rules: 'required',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE, 'number'),
      componentProps: (_values, form) => ({
        options: getDictOptions(
          DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE,
          'number',
        ),
        placeholder: '请选择周期类型',
      },
      defaultValue: 1,
        // å‘¨æœŸç±»åž‹ä¸€å˜å°±æŠŠå‘¨æœŸå€¼æ¢æˆå½“前对应周期,避免留在上一个类型的值上查不到数据
        onChange: (value: number) => {
          form.setFieldValue('periodValue', currentPeriodValue(value));
        },
      }),
      defaultValue: SALES_TARGET_PERIOD_TYPE.QUARTER,
    },
    {
      fieldName: 'periodValue',
@@ -26,7 +52,7 @@
      componentProps: {
        placeholder: '如 2026 / 2026-Q1 / 2026-07',
      },
      defaultValue: new Date().getFullYear().toString(),
      defaultValue: currentPeriodValue(SALES_TARGET_PERIOD_TYPE.QUARTER),
    },
    {
      fieldName: 'targetDimension',
src/views/crm/salesTarget/data.ts
@@ -334,7 +334,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'creatorName',
src/views/crm/statistics/customer/data.ts
@@ -8,6 +8,7 @@
  beginOfDay,
  endOfDay,
  erpCalculatePercentage,
  erpNumberFormatter,
  formatDateTime,
  handleTree,
} from '@vben/utils';
@@ -62,6 +63,7 @@
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        format: 'YYYY-MM-DD',
      },
      defaultValue: [
        formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
@@ -176,13 +178,13 @@
          field: 'createTime',
          title: '创建时间',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'orderDate',
          title: '下单日期',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
      ];
    }
@@ -234,10 +236,11 @@
          field: 'creceivablePrice',
          title: '未回款金额',
          minWidth: 200,
          // æœªå›žæ¬¾é‡‘额 = åˆåŒæ€»é‡‘额 - å›žæ¬¾é‡‘额。原来这里误用了「回款完成率」的公式,金额列上显示的是百分比。
          formatter: ({ row }) => {
            return erpCalculatePercentage(
              row.receivablePrice,
              row.contractPrice,
            return erpNumberFormatter(
              (row.contractPrice ?? 0) - (row.receivablePrice ?? 0),
              2,
            );
          },
        },
@@ -307,7 +310,7 @@
        },
        {
          field: 'ownerUserName',
          title: '日期',
          title: '员工姓名',
          minWidth: 200,
        },
        {
src/views/crm/statistics/customer/index.vue
@@ -8,12 +8,18 @@
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { erpCalculatePercentage } from '@vben/utils';
import { Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getChartDatas } from '#/api/crm/statistics/customer';
import {
  getChartDatas,
  getCustomerDealCycleByDate,
  getCustomerSummaryByDate,
  getDatas,
} from '#/api/crm/statistics/customer';
import { $t } from '#/locales';
import { getChartOptions } from './chartOptions';
@@ -43,7 +49,9 @@
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useGridColumns(activeTabName.value),
    height: 'auto',
    // height: 'auto' æ—¶è¡¨æ ¼è·Ÿçˆ¶å®¹å™¨é«˜åº¦èµ°ï¼Œæœ¬é¡µå›¾è¡¨å äº†ä¸Šé¢ä¸€å¤§å—,表格就被压得很矮;
    // è¿™é‡Œç»™ä¸ªå›ºå®šé«˜åº¦ï¼Œ8 ä¸ªé¡µç­¾å…±ç”¨è¿™ä¸€ä¸ªè¡¨æ ¼ï¼Œä¸€æ”¹å…¨é«˜ã€‚嫌高/嫌矮改这一处即可。
    height: '600px',
    keepSource: true,
    pagerConfig: {
      enabled: false,
@@ -58,6 +66,56 @@
  } as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUserRespVO>,
});
/** è¶…级管理员(用户 ID 1)不是业务人员,统计表里不该出现他名下的那行 */
const ADMIN_USER_ID = 1;
function excludeAdminRows(rows: unknown[]) {
  return (rows as Array<Record<string, unknown>>).filter(
    (row) => Number(row.ownerUserId) !== ADMIN_USER_ID,
  );
}
/**
 * æŒ‰å‘˜å·¥ç»Ÿè®¡çš„页签,后端会把部门里每个员工都列一行(没数据的补 0),按用户 ID å‡åºæŽ’ â†’ æœ‰æ•°æ®çš„员工往往在最后一行,
 * 600px ä¸€å±åªæ˜¾ç¤ºåå‡ è¡Œï¼Œç¿»å‰å‡ å±çœ‹åˆ°çš„全是 0,看着就像"没数据"。这里按主指标倒序,有数据的排前面;0 è¡Œä¸åˆ ã€‚
 */
const USER_TAB_METRIC: Record<string, string[]> = {
  customerSummary: ['customerDealCount', 'customerCreateCount'],
  dealCycleByUser: ['customerDealCount', 'customerDealCycle'],
  followUpSummary: ['followUpRecordCount', 'followUpCustomerCount'],
  poolSummary: ['customerPutCount', 'customerTakeCount'],
};
function sortByMetric(key: string, rows: unknown[]) {
  const fields = USER_TAB_METRIC[key];
  if (!fields) {
    return rows;
  }
  return [...(rows as Array<Record<string, number>>)].sort((a, b) => {
    for (const field of fields) {
      const diff = (Number(b[field]) || 0) - (Number(a[field]) || 0);
      if (diff !== 0) {
        return diff;
      }
    }
    return 0;
  });
}
/**
 * è·Ÿè¿›æ–¹å¼åˆ†æžçš„“占比(%)”列在 data.ts é‡Œæ—¢æ²¡æœ‰ formatter,后端 FollowUpSummaryByTypeRespVO ä¹Ÿä¸è¿”回该字段,
 * æ‰€ä»¥æ•´åˆ—永远是空的;这里按跟进次数在本地补算(口径与后端其它占比一致:value / total * 100,保留两位)。
 */
function fillFollowUpPortion(rows: unknown[]) {
  // getDatas çš„返回类型是各页签的联合类型,这里按调用方保证的顺序收窄成跟进方式的响应
  const list = rows as CrmStatisticsCustomerApi.FollowUpSummaryByTypeRespVO[];
  const total = list.reduce((sum, row) => sum + (row.followUpRecordCount ?? 0), 0);
  return list.map((row) => ({
    ...row,
    portion: erpCalculatePercentage(row.followUpRecordCount ?? 0, total),
  }));
}
/** tab åˆ‡æ¢ */
async function handleTabChange(key: any) {
  activeTabName.value = key;
@@ -65,9 +123,44 @@
    columns: useGridColumns(key),
  });
  const queryParams = await formApi.getValues();
  const res = await getChartDatas(activeTabName.value, queryParams);
  await renderEcharts(getChartOptions(activeTabName.value, res));
  await gridApi.grid.reloadData(res);
  // å›¾è¡¨å’Œè¡¨æ ¼æ˜¯ä¸¤ä¸ªå£å¾„的接口:图表要"按日期"的时序数据,表格要"按员工/地区/产品"的明细数据。
  // åŽŸæ¥åªè°ƒ getChartDatas åˆæŠŠå®ƒçš„返回值喂给表格,凡是两者口径不同的标签页(客户汇总、跟进汇总、客户池、转化统计)
  // è¡¨æ ¼é‡Œå°±ä¼šå¤§ç‰‡ç©ºå­—段——图表返回的日期/数量字段在表格列里并不存在。
  const [chartData, tableData] = await Promise.all([
    getChartDatas(key, queryParams),
    getDatas(key, queryParams),
  ]);
  // ã€Œå‘˜å·¥å®¢æˆ·æˆäº¤å‘¨æœŸåˆ†æžã€çš„图表选项读的是 res.customerDealCycleByDate / res.customerDealCycleByUser ä¸¤ä¸ªæ•°ç»„
  // ï¼ˆchartOptions.ts é‡Œåªæœ‰è¿™ä¸€ä¸ªé¡µç­¾æ˜¯æŒ‰å¯¹è±¡å–的),但 getChartDatas å¯¹è¿™ä¸ªé¡µç­¾åªè¿”回一个数组,
  // res.customerDealCycleByDate æ˜¯ undefined â†’ åœ¨å›¾è¡¨é‡Œ .map ç›´æŽ¥æŠ›é”™ï¼ŒåŽé¢çš„表格 reloadData æ°¸è¿œä¸ä¼šæ‰§è¡Œï¼Œ
  // è¡¨æ ¼å°±ä¸€ç›´åœåœ¨ä¸Šä¸€ä¸ªé¡µç­¾çš„æ•°æ®ä¸Šï¼ˆçœ‹ç€å°±æ˜¯"成交周期/成交客户数没有数据")。
  // è¿™é‡ŒæŒ‰å›¾è¡¨è¦çš„形状补齐两个"按日期"序列:成交周期用成交周期(按日期)接口,成交客户数用客户总量(按日期)接口,
  // ä¸¤ä¸ªæŽ¥å£éƒ½æŒ‰æŸ¥è¯¢åŒºé—´åˆ†æ¡¶ï¼Œtime ä¸€è‡´ï¼ˆå®žæµ‹ 2026-10-08(第 41 å‘¨) / 2026-10-12(第 42 å‘¨))。
  let chartPayload: any = chartData;
  if (key === 'dealCycleByUser') {
    const params = queryParams as CrmStatisticsCustomerApi.CustomerSummaryReqVO;
    const [dealCycleByDate, summaryByDate] = await Promise.all([
      getCustomerDealCycleByDate(params),
      getCustomerSummaryByDate(params),
    ]);
    chartPayload = {
      customerDealCycleByDate: dealCycleByDate,
      customerDealCycleByUser: summaryByDate,
    };
  }
  // å›¾è¡¨ç”»ä¸å‡ºæ¥ä¸èƒ½è¿žç´¯è¡¨æ ¼ï¼šå®¹é”™åŒ…一层,异常只记日志
  try {
    await renderEcharts(getChartOptions(key, chartPayload));
  } catch (error) {
    console.error('[客户分析] å›¾è¡¨æ¸²æŸ“失败', key, error);
  }
  const rows = sortByMetric(
    key,
    excludeAdminRows(Array.isArray(tableData) ? tableData : []),
  );
  await gridApi.grid.reloadData(
    key === 'followUpType' ? fillFollowUpPortion(rows) : rows,
  );
}
onMounted(() => {
src/views/crm/statistics/funnel/data.ts
@@ -36,6 +36,7 @@
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        format: 'YYYY-MM-DD',
      },
      defaultValue: [
        formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
@@ -116,7 +117,7 @@
          field: 'dealTime',
          title: '预计成交日期',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'ownerUserName',
@@ -132,19 +133,19 @@
          field: 'contactLastTime',
          title: '最后跟进时间',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'updateTime',
          title: '更新时间',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'createTime',
          title: '创建时间',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'creatorName',
@@ -189,7 +190,7 @@
          field: 'dealTime',
          title: '预计成交日期',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'ownerUserName',
@@ -205,19 +206,19 @@
          field: 'contactLastTime',
          title: '最后跟进时间',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'updateTime',
          title: '更新时间',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'createTime',
          title: '创建时间',
          minWidth: 200,
          formatter: 'formatDateTime',
          formatter: 'formatDate',
        },
        {
          field: 'creatorName',
src/views/crm/statistics/portrait/chartOptions.ts
@@ -78,6 +78,21 @@
  series: [getPieSeries(seriesName, data)],
});
/**
 * ä¸­å›½åœ°å›¾ï¼šECharts é»˜è®¤åªæŠŠåœ°å›¾æ’‘到容器高度的 80%,容器加宽并不会让地图变大。
 * ç”¨ layoutCenter + layoutSize æŠŠåœ°å›¾é¡¶åˆ° 92%,并稍微下移给标题留位置。
 */
const getMapSeries = (name: string, data: any[]) => ({
  name,
  type: 'map' as const,
  map: 'china',
  roam: false,
  selectedMode: false,
  layoutCenter: ['50%', '52%'],
  layoutSize: '92%',
  data,
});
export function getChartOptions(activeTabName: any, res: any): any {
  switch (activeTabName) {
    case 'area': {
@@ -120,19 +135,15 @@
            max: leftMax,
          },
          series: [
            {
              name: '客户地域分布',
              type: 'map',
              map: 'china',
              roam: false,
              selectedMode: false,
              data: data.map((item: any) => {
            getMapSeries(
              '客户地域分布',
              data.map((item: any) => {
                return {
                  name: item.areaName,
                  value: item.customerCount || 0,
                };
              }),
            },
            ),
          ],
        },
        right: {
@@ -157,19 +168,15 @@
            max: rightMax,
          },
          series: [
            {
              name: '客户地域分布',
              type: 'map',
              map: 'china',
              roam: false,
              selectedMode: false,
              data: data.map((item: any) => {
            getMapSeries(
              '客户地域分布',
              data.map((item: any) => {
                return {
                  name: item.areaName,
                  value: item.dealCount || 0,
                };
              }),
            },
            ),
          ],
        },
      };
src/views/crm/statistics/portrait/data.ts
@@ -5,6 +5,8 @@
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import dayjs from 'dayjs';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -39,10 +41,11 @@
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        format: 'YYYY-MM-DD',
      },
      defaultValue: [
        formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
        formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
        formatDateTime(beginOfDay(dayjs().subtract(2, 'month').toDate())),
        formatDateTime(endOfDay(new Date())),
      ],
    },
    {
src/views/crm/statistics/portrait/index.vue
@@ -4,7 +4,7 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
@@ -24,6 +24,15 @@
const rightChartRef = ref<EchartsUIType>();
const { renderEcharts: renderLeftEcharts } = useEcharts(leftChartRef);
const { renderEcharts: renderRightEcharts } = useEcharts(rightChartRef);
/**
 * ä¸¤å¼ å›¾å§‹ç»ˆä¸€æŽ’两个(地图也不例外)。
 * åœ°å›¾å¤§å°ç”±å®¹å™¨çŸ­è¾¹ Ã— 0.92 å†³å®šï¼ˆECharts é»˜è®¤åªç»™çŸ­è¾¹çš„ 80%,加宽容器不会放大图形,
 * åªæ˜¯å¤šç•™ç™½ï¼‰ï¼Œæ‰€ä»¥åœ°å›¾åªèƒ½é åŠ é«˜æ¥æ”¾å¤§â€”â€”700px æ˜¯è®©è¿™ä¸€è¡Œä¸è‡³äºŽæŠŠé¡µé¢é¡¶å‡ºå±å¹•的高度。
 * é¥¼å›¾æ²¿ç”¨åŽŸæ¥çš„ 300px。
 */
const isAreaTab = computed(() => activeTabName.value === 'area');
const chartHeight = computed(() => (isAreaTab.value ? '700px' : '300px'));
const [QueryForm, formApi] = useVbenForm({
  commonConfig: {
@@ -94,8 +103,16 @@
        />
      </Tabs>
      <div class="mt-5 flex">
        <EchartsUI class="m-4 w-1/2" ref="leftChartRef" />
        <EchartsUI class="m-4 w-1/2" ref="rightChartRef" />
        <EchartsUI
          class="m-4 w-1/2"
          :height="chartHeight"
          ref="leftChartRef"
        />
        <EchartsUI
          class="m-4 w-1/2"
          :height="chartHeight"
          ref="rightChartRef"
        />
      </div>
      <Grid v-show="activeTabName !== 'area'" />
    </ContentWrap>
src/views/crm/statistics/rank/data.ts
@@ -4,6 +4,8 @@
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import dayjs from 'dayjs';
import { getSimpleDeptList } from '#/api/system/dept';
import { getRangePickerDefaultProps } from '#/utils';
@@ -53,10 +55,11 @@
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        format: 'YYYY-MM-DD',
      },
      defaultValue: [
        formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
        formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
        formatDateTime(beginOfDay(dayjs().subtract(2, 'month').toDate())),
        formatDateTime(endOfDay(new Date())),
      ],
    },
    {
src/views/crm/statistics/rank/index.vue
@@ -2,7 +2,7 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import type { CrmStatisticsRankApi } from '#/api/crm/statistics/rank';
import { onMounted, ref } from 'vue';
@@ -13,7 +13,7 @@
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDatas } from '#/api/crm/statistics/customer';
import { getDatas } from '#/api/crm/statistics/rank';
import { $t } from '#/locales';
import { getChartOptions } from './chartOptions';
@@ -48,15 +48,6 @@
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      ajax: {
        query: async (_, formValues) => {
          const res = await getDatas(activeTabName.value, formValues);
          await renderEcharts(getChartOptions(activeTabName.value, res));
          return res;
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
@@ -64,7 +55,7 @@
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUserRespVO>,
  } as VxeTableGridOptions<CrmStatisticsRankApi.RankRespVO>,
});
/** tab åˆ‡æ¢ */
src/views/dashboard/analytics/index.vue
@@ -3,8 +3,9 @@
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { Card, Col, Row } from 'ant-design-vue';
import { Col, Row } from 'ant-design-vue';
import { getHomeSummary } from '#/api/mes/home';
@@ -15,11 +16,13 @@
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
import QuickActions from './modules/quick-actions.vue';
import { useHomeAccess } from './use-home-access';
defineOptions({ name: 'DashboardAnalytics' });
const router = useRouter();
const summary = ref(defaultSummary);
const { canNavigate, canViewHome } = useHomeAccess();
const greeting = computed(() => {
  const hour = new Date().getHours();
@@ -38,10 +41,12 @@
});
function handleNavigate(name: string) {
  if (!canNavigate(name)) return;
  router.push({ name });
}
async function loadSummary() {
  if (!canViewHome.value) return;
  summary.value = await getHomeSummary();
}
@@ -72,19 +77,20 @@
    </div>
    <!-- MES ç”Ÿäº§ KPI -->
    <KpiCards :summary="summary" @navigate="handleNavigate" />
    <KpiCards v-if="canViewHome" :summary="summary" @navigate="handleNavigate" />
    <!-- ERP/WMS/BPM ç»è¥ KPI -->
    <BizCards @navigate="handleNavigate" />
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      v-if="canViewHome && canNavigate('MesProWorkOrder') && isAiEnable()"
      :active-order-count="summary.workOrderActiveCount"
      @navigate="handleNavigate"
    />
    <!-- ç”Ÿäº§è¶‹åŠ¿ + é¢„警面板 -->
    <Row :gutter="16" class="mb-4">
    <Row v-if="canViewHome" :gutter="16" class="mb-4">
      <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4">
        <div class="glass-card rounded-2xl p-5">
          <h3 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-100">
src/views/dashboard/analytics/modules/alert-panel.vue
@@ -12,6 +12,8 @@
import { getWorkOrderStatusDistribution } from '#/api/mes/home';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardAlertPanel' });
const props = defineProps<{
@@ -25,8 +27,10 @@
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(false);
const { canNavigate, canViewHome } = useHomeAccess();
async function loadStatus() {
  if (!canViewHome.value) return;
  loading.value = true;
  try {
    const data = await getWorkOrderStatusDistribution();
@@ -68,6 +72,7 @@
    <!-- é¢„警卡片行 -->
    <div class="grid grid-cols-1 gap-3">
      <div
        v-if="canNavigate('MesProRepair')"
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
        @click="emit('navigate', 'MesProRepair')"
      >
src/views/dashboard/analytics/modules/biz-cards.vue
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
@@ -11,6 +11,8 @@
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import { getTaskTodoPage } from '#/api/bpm/task';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardBizCards' });
const emit = defineEmits<{
@@ -21,8 +23,14 @@
const saleCount = ref(0);
const stockCount = ref(0);
const bpmTodoCount = ref(0);
const { canNavigate, canQuery } = useHomeAccess();
const canViewPurchase = computed(() => canQuery('erp:purchase-order:query') && canNavigate('ErpPurchaseOrder'));
const canViewSale = computed(() => canQuery('erp:sale-order:query') && canNavigate('ErpSaleOrder'));
const canViewStock = computed(() => canQuery('mes:wm-material-stock:query') && canNavigate('MesWmMaterialStock'));
const canViewBpmTodo = computed(() => canQuery('bpm:task:query') && canNavigate('BpmTaskTodo'));
async function loadPurchase() {
  if (!canViewPurchase.value) return;
  try {
    const result = await getPurchaseOrderPage({ pageNo: 1, pageSize: 1 });
    purchaseCount.value = result.total ?? 0;
@@ -30,6 +38,7 @@
}
async function loadSale() {
  if (!canViewSale.value) return;
  try {
    const result = await getSaleOrderPage({ pageNo: 1, pageSize: 1 });
    saleCount.value = result.total ?? 0;
@@ -37,6 +46,7 @@
}
async function loadStock() {
  if (!canViewStock.value) return;
  try {
    const result = await getMaterialStockPage({ pageNo: 1, pageSize: 1 });
    stockCount.value = result.total ?? 0;
@@ -44,6 +54,7 @@
}
async function loadBpmTodo() {
  if (!canViewBpmTodo.value) return;
  try {
    const result = await getTaskTodoPage({ pageNo: 1, pageSize: 1 });
    bpmTodoCount.value = result.total ?? 0;
@@ -83,9 +94,9 @@
</script>
<template>
  <Row :gutter="16">
  <Row v-if="canViewPurchase || canViewSale || canViewStock || canViewBpmTodo" :gutter="16">
    <!-- é‡‡è´­è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewPurchase" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('purchase')"
@@ -112,7 +123,7 @@
    </Col>
    <!-- é”€å”®è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewSale" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('sale')"
@@ -139,7 +150,7 @@
    </Col>
    <!-- ç‰©æ–™åº“å­˜ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewStock" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('stock')"
@@ -166,7 +177,7 @@
    </Col>
    <!-- BPM å¾…办 -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewBpmTodo" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('bpm')"
src/views/dashboard/analytics/modules/kpi-cards.vue
@@ -8,7 +8,11 @@
import { Col, Row } from 'ant-design-vue';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardKpiCards' });
const { canNavigate } = useHomeAccess();
const props = defineProps<{
  summary: MesHomeApi.Summary;
@@ -18,12 +22,60 @@
  navigate: [name: string];
}>();
const qualityRate = computed(() => {
  const total =
    props.summary.todayQualifiedQuantity +
    props.summary.todayUnqualifiedQuantity;
  return total === 0 ? 0 : (props.summary.todayQualifiedQuantity / total) * 100;
});
/**
 * è´¨é‡åˆæ ¼çŽ‡ï¼šæ¥æ–™ / è¿‡ç¨‹ / å‡ºè´§ä¸‰ä¸ªæ£€éªŒçŽ¯èŠ‚ï¼Œå„è‡ªä¸€å¼ å¡ç‰‡
 *
 * ç”±åŽç«¯æŒ‰ã€Œåˆæ ¼æ•°é‡ / æ£€éªŒæ•°é‡ã€è®¡ç®—(不过滤单据状态,无时间限制),
 * è¯¥çŽ¯èŠ‚æ²¡æœ‰æ£€éªŒæ•°æ®æ—¶åŽç«¯è¿”å›ž null。
 */
const qualityRates = computed(() => [
  {
    key: 'quality-iqc',
    label: '来料合格率',
    rate: props.summary.iqcQualifiedRate,
    checkQuantity: props.summary.iqcCheckQuantity,
    routeName: 'MesQcIqc',
    icon: 'lucide:package-check',
    iconClass: 'bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25',
    textClass: 'text-orange-600 dark:text-orange-400',
    washClass: 'bg-gradient-to-br from-orange-500/5 to-amber-500/5',
    glowClass: 'bg-orange-400/10',
  },
  {
    key: 'quality-ipqc',
    label: '过程合格率',
    rate: props.summary.ipqcQualifiedRate,
    checkQuantity: props.summary.ipqcCheckQuantity,
    routeName: 'MesQcIpqc',
    icon: 'lucide:workflow',
    iconClass: 'bg-gradient-to-br from-cyan-500 to-teal-400 shadow-lg shadow-cyan-500/25',
    textClass: 'text-cyan-600 dark:text-cyan-400',
    washClass: 'bg-gradient-to-br from-cyan-500/5 to-teal-500/5',
    glowClass: 'bg-cyan-400/10',
  },
  {
    key: 'quality-oqc',
    label: '出货合格率',
    rate: props.summary.oqcQualifiedRate,
    checkQuantity: props.summary.oqcCheckQuantity,
    routeName: 'MesQcOqc',
    icon: 'lucide:truck',
    iconClass: 'bg-gradient-to-br from-pink-500 to-rose-400 shadow-lg shadow-pink-500/25',
    textClass: 'text-pink-600 dark:text-pink-400',
    washClass: 'bg-gradient-to-br from-pink-500/5 to-rose-500/5',
    glowClass: 'bg-pink-400/10',
  },
]);
/** åˆæ ¼çŽ‡å±•ç¤ºæ–‡æ¡ˆï¼ˆä¸€ä½å°æ•°ï¼Œä¸ŽåŽŸã€Œè´¨é‡åˆæ ¼çŽ‡ã€å¡ç‰‡ä¸€è‡´ï¼‰ï¼Œæ— æ•°æ®æ—¶ç”¨ã€Œâ€”ã€è€Œä¸æ˜¯ 0% */
function formatRate(rate: null | number | undefined) {
  return rate === null || rate === undefined ? '—' : rate.toFixed(1);
}
/** æ— æ£€éªŒæ•°æ®æ—¶ä¸æ¸²æŸ“百分号,避免出现「—%」 */
function hasRate(rate: null | number | undefined) {
  return rate !== null && rate !== undefined;
}
const equipmentRate = computed(() => {
  if (props.summary.machineryTotal === 0) return 0;
@@ -59,13 +111,14 @@
<template>
  <Row :gutter="16">
    <!-- ç”Ÿäº§è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesProWorkOrder') }"
        :style="tiltStyle('order')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'order')"
        @mouseleave="onMouseLeave('order')"
        @click="emit('navigate', 'MesProWorkOrder')"
        @click="canNavigate('MesProWorkOrder') && emit('navigate', 'MesProWorkOrder')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 shadow-lg shadow-blue-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -90,13 +143,14 @@
    </Col>
    <!-- ä»Šæ—¥äº§é‡ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesProFeedback') }"
        :style="tiltStyle('output')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'output')"
        @mouseleave="onMouseLeave('output')"
        @click="emit('navigate', 'MesProFeedback')"
        @click="canNavigate('MesProFeedback') && emit('navigate', 'MesProFeedback')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-green-400 shadow-lg shadow-emerald-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -120,47 +174,65 @@
      </div>
    </Col>
    <!-- è´¨é‡åˆæ ¼çއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <!-- è´¨é‡åˆæ ¼çŽ‡ï¼šæ¥æ–™ / è¿‡ç¨‹ / å‡ºè´§ï¼Œå„一张卡片 -->
    <Col
      v-for="item in qualityRates"
      :key="item.key"
      :lg="6"
      :md="12"
      :sm="12"
      :xl="4"
      :xs="24"
      class="mb-4"
    >
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('quality')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'quality')"
        @mouseleave="onMouseLeave('quality')"
        @click="emit('navigate', 'MesQcRecord')"
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate(item.routeName) }"
        :style="tiltStyle(item.key)"
        @mousemove="(e: MouseEvent) => onMouseMove(e, item.key)"
        @mouseleave="onMouseLeave(item.key)"
        @click="canNavigate(item.routeName) && emit('navigate', item.routeName)"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25 transition-transform duration-300 group-hover:scale-110">
            <IconifyIcon class="size-6 text-white" icon="lucide:shield-check" />
          <div
            class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
            :class="item.iconClass"
          >
            <IconifyIcon class="size-6 text-white" :icon="item.icon" />
          </div>
          <div class="min-w-0 flex-1">
            <div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">质量合格率</div>
            <div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">{{ item.label }}</div>
            <div class="flex items-baseline gap-1">
              <CountTo
                class="text-2xl font-bold leading-tight"
                :class="qualityRate >= 95 ? 'text-emerald-600 dark:text-emerald-400' : qualityRate >= 80 ? 'text-orange-600 dark:text-orange-400' : 'text-red-600 dark:text-red-400'"
                :decimals="1" :end-val="qualityRate" :duration="1200"
              />
              <span class="text-xs text-gray-400">%</span>
              <span class="text-2xl font-bold leading-tight" :class="item.textClass">
                {{ formatRate(item.rate) }}
              </span>
              <span v-if="hasRate(item.rate)" class="text-xs text-gray-400">%</span>
            </div>
            <div class="mt-1 text-xs text-gray-400">
              åˆæ ¼ {{ summary.todayQualifiedQuantity }} | ä¸è‰¯ {{ summary.todayUnqualifiedQuantity }}
            <div class="mt-1 flex items-center gap-1 text-xs text-gray-400">
              <span>检验 {{ item.checkQuantity }}</span>
            </div>
          </div>
        </div>
        <div class="absolute inset-0 -z-0 bg-gradient-to-br from-orange-500/5 to-amber-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
        <div class="absolute -right-6 -top-6 size-24 rounded-full bg-orange-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
        <div
          class="absolute inset-0 -z-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
          :class="item.washClass"
        />
        <div
          class="absolute -right-6 -top-6 size-24 rounded-full blur-xl transition-all duration-300 group-hover:scale-150"
          :class="item.glowClass"
        />
      </div>
    </Col>
    <!-- è®¾å¤‡è¿è¡Œçއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesDvMachinery') }"
        :style="tiltStyle('equip')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'equip')"
        @mouseleave="onMouseLeave('equip')"
        @click="emit('navigate', 'MesDvMachinery')"
        @click="canNavigate('MesDvMachinery') && emit('navigate', 'MesDvMachinery')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-400 shadow-lg shadow-violet-500/25 transition-transform duration-300 group-hover:scale-110">
src/views/dashboard/analytics/modules/production-trend.vue
@@ -2,18 +2,22 @@
import type { MesHomeApi } from '#/api/mes/home';
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref, watch } from 'vue';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { getProductionTrend } from '#/api/mes/home';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardProductionTrend' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const { canViewHome } = useHomeAccess();
async function loadTrend() {
  if (!canViewHome.value) return;
  const data = await getProductionTrend(7);
  const dates = data.map((d: MesHomeApi.ProductionTrend) => d.date.slice(5));
  const quantities = data.map((d: MesHomeApi.ProductionTrend) => d.quantity);
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -1,5 +1,9 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardQuickActions' });
@@ -25,14 +29,17 @@
  { 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' },
];
const { canNavigate } = useHomeAccess();
const accessibleActions = computed(() => actions.filter((action) => canNavigate(action.route)));
</script>
<template>
  <div class="glass-card rounded-2xl p-5">
  <div v-if="accessibleActions.length" 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-4 gap-3 sm:grid-cols-8">
      <div
        v-for="action in actions"
        v-for="action in accessibleActions"
        :key="action.route"
        class="group flex cursor-pointer flex-col items-center gap-2 rounded-xl p-3 transition-all duration-300 hover:-translate-y-1"
        @click="emit('navigate', action.route)"
src/views/dashboard/analytics/use-home-access.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
import type { RouteRecordRaw } from 'vue-router';
import { computed } from 'vue';
import { useAccess } from '@vben/access';
import { useAccessStore } from '@vben/stores';
/** é¦–页常驻,业务数据和入口仍需遵循当前用户的菜单权限。 */
export function useHomeAccess() {
  const accessStore = useAccessStore();
  const { hasAccessByCodes } = useAccess();
  function canQuery(permission: string) {
    return hasAccessByCodes([permission, '*:*:*']);
  }
  function canNavigate(name: string) {
    function contains(routes: RouteRecordRaw[]): boolean {
      return routes.some((route) => {
        if (route.meta?.menuVisibleWithForbidden) return false;
        return route.name === name || contains(route.children ?? []);
      });
    }
    return contains(accessStore.accessRoutes);
  }
  const canViewHome = computed(() => canQuery('mes:home:query'));
  return { canNavigate, canQuery, canViewHome };
}
src/views/energy/alert/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { FormInstance, TablePaginationConfig } from 'ant-design-vue';
import type { EnergyAlertRule, EnergyAlertLog, EnergyType } from '#/api/energy';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Card, DatePicker, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, TabPane, Tag, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { checkEnergyAlerts, deleteEnergyAlertRule, getEnergyAlertLogs, getEnergyAlertRecipients, getEnergyAlertRules, saveEnergyAlertRule } from '#/api/energy';
import { energyInfo, energyOptions, numberText, productOptions } from '../shared';
defineOptions({ name: 'EnergyAlert' });
const tab = ref('rules');
const rules = ref<EnergyAlertRule[]>([]);
const logs = ref<EnergyAlertLog[]>([]);
const recipients = ref<{ label: string; value: number }[]>([]);
const loading = ref(false), error = ref(false), saving = ref(false), checking = ref(false), open = ref(false);
const rulePage = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const logPage = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const logFilters = reactive<{ date?: string; status?: string }>({});
const checkDate = ref(dayjs().format('YYYY-MM-DD'));
const initial = (): EnergyAlertRule => ({ name: '', energyType: 'WATER', metric: 'QUANTITY', threshold: 1, enabled: true, recipientIds: [], usageArea: '', productCategory: undefined });
const model = reactive<EnergyAlertRule>(initial());
const metricOptions = [
  { label: '每日用量', value: 'QUANTITY' }, { label: '每日费用', value: 'AMOUNT' },
  { label: '每月用量', value: 'MONTH_QUANTITY' }, { label: '每月费用', value: 'MONTH_AMOUNT' },
];
const metricLabel = (metric: string) => metricOptions.find(option => option.value === metric)?.label ?? metric;
const isAmount = (metric: string) => metric.endsWith('AMOUNT');
const formRef = ref<FormInstance>();
const unit = computed(() => isAmount(model.metric) ? '元' : energyInfo[model.energyType].unit);
const validation = {
  name: [{ required: true, whitespace: true, message: '请输入规则名称' }],
  threshold: [{ required: true, type: 'number' as const, min: 0.001, message: '阈值必须大于零' }],
  recipientIds: [{ required: true, type: 'array' as const, min: 1, max: 50, message: '请选择 1 è‡³ 50 ä½æŽ¥æ”¶äºº' }],
};
const ruleColumns = [
  { title: '规则名称', dataIndex: 'name', width: 180 }, { title: '能源', dataIndex: 'energyType', width: 80 },
  { title: '统计范围', key: 'scope', width: 200 }, { title: '统计指标 / é˜ˆå€¼', key: 'threshold', width: 190 },
  { title: '接收人', key: 'recipients', width: 180 }, { title: '状态', key: 'enabled', width: 80 },
  { title: '操作', key: 'action', width: 140, fixed: 'right' as const },
];
const logColumns = [
  { title: '统计周期', key: 'period', width: 120 }, { title: '规则名称', dataIndex: 'ruleName', width: 160 },
  { title: '能源 / èŒƒå›´', key: 'scope', width: 230 }, { title: '实际值 / é˜ˆå€¼', key: 'value', width: 210 },
  { title: '接收人', dataIndex: 'recipientName', width: 120 }, { title: '结果', key: 'status', width: 100 },
  { title: '失败原因', dataIndex: 'errorMessage', width: 300 },
];
let loadId = 0;
async function load() {
  const id = ++loadId; loading.value = true; error.value = false;
  try {
    if (tab.value === 'rules') {
      const result = await getEnergyAlertRules({ pageNo: rulePage.current, pageSize: rulePage.pageSize });
      if (id === loadId) { rules.value = result.list; rulePage.total = result.total; }
    } else {
      const result = await getEnergyAlertLogs({ ...logFilters, pageNo: logPage.current, pageSize: logPage.pageSize });
      if (id === loadId) { logs.value = result.list; logPage.total = result.total; }
    }
  } catch { if (id === loadId) error.value = true; }
  finally { if (id === loadId) loading.value = false; }
}
function changePage(page: TablePaginationConfig) {
  Object.assign(tab.value === 'rules' ? rulePage : logPage, { current: page.current ?? 1, pageSize: page.pageSize ?? 10 }); void load();
}
async function showForm(row?: EnergyAlertRule) {
  try { recipients.value = (await getEnergyAlertRecipients()).map(user => ({ label: user.nickname, value: user.id })); }
  catch { message.error('无法加载接收人,请重试'); return; }
  Object.keys(model).forEach(key => delete (model as unknown as Record<string, unknown>)[key]);
  Object.assign(model, row ? { ...row, recipientIds: [...row.recipientIds] } : initial());
  open.value = true; formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate(); saving.value = true;
  try { await saveEnergyAlertRule({ ...model }); open.value = false; message.success('规则已保存,后续录入自动检查;历史日期可手动检查'); await load(); }
  finally { saving.value = false; }
}
async function remove(id: number) { await deleteEnergyAlertRule(id); message.success('已删除,历史通知记录保留'); rulePage.current = 1; await load(); }
async function check() {
  if (!checkDate.value || checkDate.value > dayjs().format('YYYY-MM-DD')) { message.warning('请选择今天或之前的日期'); return; }
  checking.value = true;
  try {
    const result = await checkEnergyAlerts(checkDate.value);
    message[result.failed ? 'warning' : 'success'](`检查完成:新发送 ${result.sent} æ¡ï¼Œå¤±è´¥ ${result.failed} æ¡ã€‚已发送的通知不会重复发送。`);
    tab.value = 'logs'; logFilters.date = checkDate.value; logPage.current = 1; await load();
  } finally { checking.value = false; }
}
function recipientNames(ids: number[]) { return ids.map(id => recipients.value.find(user => user.value === id)?.label ?? `用户 ${id}`).join('、'); }
onMounted(async () => {
  await load();
  try { recipients.value = (await getEnergyAlertRecipients()).map(user => ({ label: user.nickname, value: user.id })); } catch { /* Rules remain viewable when recipient lookup fails. */ }
});
</script>
<template>
  <Page title="能耗预警" description="配置每日或每月用量、费用阈值,超过阈值时向指定人员发送站内通知。">
    <Alert class="mb-4" type="info" show-icon message="每日指标按天汇总,每月指标按自然月汇总本月已录入数据,超限即通知。同一规则、统计周期和接收人只成功通知一次。检查日期同时检查当天及所属月份。区域按名称精确匹配。" />
    <Card>
      <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
        <Button v-access:code="['energy:alert:create']" type="primary" @click="showForm()">新增预警规则</Button>
        <Space wrap><span>检查日期</span><DatePicker v-model:value="checkDate" value-format="YYYY-MM-DD" :allow-clear="false" /><Button v-access:code="['energy:alert:check']" :loading="checking" @click="check">检查预警 / é‡è¯•失败通知</Button></Space>
      </div>
      <Alert v-if="error" class="mb-4" type="error" show-icon message="加载失败,请点击刷新重试。" />
      <Tabs v-model:active-key="tab" @change="load">
        <TabPane key="rules" tab="预警规则">
          <Table :columns="ruleColumns" :data-source="rules" row-key="id" :loading="loading" :pagination="rulePage" :scroll="{ x: 1100 }" :locale="{ emptyText: '暂无规则,请先设置阈值和通知接收人' }" @change="changePage">
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
              <template v-else-if="column.key === 'scope'">{{ record.usageArea || '全部区域' }} / {{ record.productCategory || '全部产品' }}</template>
              <template v-else-if="column.key === 'threshold'">{{ metricLabel(record.metric) }} &gt; {{ numberText(record.threshold) }} {{ isAmount(record.metric) ? '元' : energyInfo[record.energyType as EnergyType].unit }}</template>
              <template v-else-if="column.key === 'recipients'">{{ recipientNames(record.recipientIds) }}</template>
              <template v-else-if="column.key === 'enabled'"><Tag :color="record.enabled ? 'green' : 'default'">{{ record.enabled ? '启用' : '停用' }}</Tag></template>
              <template v-else-if="column.key === 'action'"><Space :size="0"><Button v-access:code="['energy:alert:update']" type="link" @click="showForm(record as EnergyAlertRule)">编辑</Button><Popconfirm title="删除此规则?历史通知仍会保留。" @confirm="remove(record.id)"><Button v-access:code="['energy:alert:delete']" type="link" danger>删除</Button></Popconfirm></Space></template>
            </template>
          </Table>
        </TabPane>
        <TabPane key="logs" tab="通知记录">
          <Space class="mb-4" wrap><DatePicker v-model:value="logFilters.date" value-format="YYYY-MM-DD" placeholder="统计日期" /><Select v-model:value="logFilters.status" :options="[{ label: '发送成功', value: 'SENT' }, { label: '发送失败', value: 'FAILED' }]" allow-clear placeholder="全部结果" style="width: 140px" /><Button @click="logPage.current = 1; load()">查询</Button></Space>
          <Table :columns="logColumns" :data-source="logs" row-key="id" :loading="loading" :pagination="logPage" :scroll="{ x: 1340 }" :locale="{ emptyText: '暂无预警通知记录' }" @change="changePage">
            <template #bodyCell="{ column, record }">
              <template v-if="column.key === 'period'">{{ record.periodType === 'MONTH' ? record.recordDate.slice(0, 7) : record.recordDate }}<div class="text-xs text-muted-foreground">{{ metricLabel(record.metric) }}</div></template>
              <template v-else-if="column.key === 'scope'">{{ energyInfo[record.energyType as EnergyType].name }} / {{ record.scope }}</template>
              <template v-else-if="column.key === 'value'">{{ numberText(record.actualValue) }} / {{ numberText(record.threshold) }} {{ isAmount(record.metric) ? '元' : energyInfo[record.energyType as EnergyType].unit }}</template>
              <template v-else-if="column.key === 'status'"><Tag :color="record.status === 'SENT' ? 'green' : 'red'">{{ record.status === 'SENT' ? '发送成功' : '发送失败' }}</Tag></template>
            </template>
          </Table>
        </TabPane>
        <template #rightExtra><Button :loading="loading" @click="load">刷新</Button></template>
      </Tabs>
    </Card>
    <Modal v-model:open="open" :title="model.id ? '编辑预警规则' : '新增预警规则'" :confirm-loading="saving" :mask-closable="false" :width="640" @ok="save">
      <Form ref="formRef" :model="model" :rules="validation" layout="vertical" class="mt-4">
        <FormItem label="规则名称" name="name"><Input v-model:value="model.name" :maxlength="100" placeholder="例如:燕窝生产区每日用水预警" /></FormItem>
        <div class="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
          <FormItem label="能源"><Select v-model:value="model.energyType" :options="energyOptions" /></FormItem>
          <FormItem label="统计指标"><Select v-model:value="model.metric" :options="metricOptions" /></FormItem>
          <FormItem label="使用区域(精确匹配,可留空)"><Input v-model:value="model.usageArea" :maxlength="100" placeholder="留空统计全部区域" /></FormItem>
          <FormItem label="产品类别"><Select v-model:value="model.productCategory" :options="productOptions" allow-clear placeholder="全部产品" /></FormItem>
        </div>
        <FormItem :label="`${model.metric.startsWith('MONTH_') ? '每月' : '每日'}阈值(${unit},大于此值时通知)`" name="threshold"><InputNumber v-model:value="model.threshold" :min="0.001" :precision="3" style="width: 100%" /></FormItem>
        <FormItem label="通知接收人" name="recipientIds"><Select v-model:value="model.recipientIds" mode="multiple" :options="recipients" option-filter-prop="label" placeholder="选择本站内用户" /></FormItem>
        <FormItem label="启用规则"><Switch v-model:checked="model.enabled" /></FormItem>
      </Form>
    </Modal>
  </Page>
</template>
src/views/energy/analysis/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,232 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { EnergyAnalysis, EnergyFilters, EnergyType } from '#/api/energy';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Alert, Button, Card, DatePicker, Empty, Form, FormItem, Input, RadioGroup, Select, Space, Spin, Table, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { exportEnergyAnalysis, getEnergyAnalysis } from '#/api/energy';
import { energyInfo, energyOptions, numberText, productOptions } from '../shared';
defineOptions({ name: 'EnergyAnalysis' });
const defaults = () => ({ startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD'), groupBy: 'DAY' as 'DAY' | 'MONTH' });
const filters = reactive<EnergyFilters & ReturnType<typeof defaults>>(defaults());
const applied = ref({ ...filters });
const data = ref<EnergyAnalysis>();
const loading = ref(false);
const failed = ref(false);
const exporting = ref(false);
const selectedEnergy = ref<EnergyType>('WATER');
const trendRef = ref<EchartsUIType>();
const shareRef = ref<EchartsUIType>();
const areaRef = ref<EchartsUIType>();
const trendChart = useEcharts(trendRef);
const shareChart = useEcharts(shareRef);
const areaChart = useEcharts(areaRef);
const costRef = ref<EchartsUIType>();
const areaShareRef = ref<EchartsUIType>();
const usageRef = ref<EchartsUIType>();
const costChart = useEcharts(costRef);
const areaShareChart = useEcharts(areaShareRef);
const usageChart = useEcharts(usageRef);
const toolbox = (name: string) => ({ right: 8, feature: { saveAsImage: { title: '下载 PNG å›¾ç‰‡', name, pixelRatio: 2 } } });
async function exportReport() {
  exporting.value = true;
  try { downloadFileFromBlobPart({ fileName: '能耗分析.xlsx', source: await exportEnergyAnalysis({ ...applied.value }) }); }
  finally { exporting.value = false; }
}
const hasRecords = computed(() => (data.value?.recordCount ?? 0) > 0);
const summaryColumns = [
  { title: '能源', dataIndex: 'energyType' },
  { title: '总用量', dataIndex: 'quantity', align: 'right' as const },
  { title: '费用(元)', dataIndex: 'amount', align: 'right' as const },
  { title: '费用占比', key: 'share', align: 'right' as const },
  { title: '录入条数', dataIndex: 'recordCount', align: 'right' as const },
];
const detailColumns = [
  { title: '日期 / æœˆä»½', dataIndex: 'label' },
  { title: '能源', dataIndex: 'energyType' },
  { title: '用量', dataIndex: 'quantity', align: 'right' as const },
  { title: '费用(元)', dataIndex: 'amount', align: 'right' as const },
  { title: '录入条数', dataIndex: 'recordCount', align: 'right' as const },
];
let requestId = 0;
async function load() {
  if (!filters.startDate || !filters.endDate || filters.startDate > filters.endDate) {
    message.warning('请选择有效的开始和结束日期'); return;
  }
  if (dayjs(filters.endDate).isAfter(dayjs(filters.startDate).add(2, 'year'))) {
    message.warning('每次分析请选择两年以内的日期范围'); return;
  }
  const id = ++requestId;
  const params = { ...filters };
  loading.value = true;
  failed.value = false;
  try {
    const result = await getEnergyAnalysis(params);
    if (id !== requestId) return;
    data.value = result;
    applied.value = params;
    selectedEnergy.value = params.energyType ?? 'WATER';
    await nextTick();
    await renderCharts();
  } catch { if (id === requestId) { data.value = undefined; failed.value = true; } }
  finally { if (id === requestId) loading.value = false; }
}
function reset() {
  Object.keys(filters).forEach(key => delete (filters as Record<string, unknown>)[key]);
  Object.assign(filters, defaults());
  void load();
}
async function renderTrend() {
  if (!hasRecords.value || !data.value) return;
  const info = energyInfo[selectedEnergy.value];
  const month = applied.value.groupBy === 'MONTH';
  const periods: string[] = [];
  let cursor = dayjs(applied.value.startDate).startOf(month ? 'month' : 'day');
  const end = dayjs(applied.value.endDate);
  while (!cursor.isAfter(end)) {
    periods.push(cursor.format(month ? 'YYYY-MM' : 'YYYY-MM-DD'));
    cursor = cursor.add(1, month ? 'month' : 'day');
  }
  const values = new Map(data.value.trend.filter(row => row.energyType === selectedEnergy.value).map(row => [row.label, Number(row.quantity)]));
  await trendChart.renderEcharts({
    animation: false,
    toolbox: toolbox(`${info.name}用量趋势`),
    tooltip: { trigger: 'axis', confine: true },
    grid: { left: 20, right: 24, top: 40, bottom: 70, containLabel: true },
    xAxis: { type: 'category', data: periods },
    yAxis: { type: 'value', name: `${info.name}用量(${info.unit})` },
    dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 12 }],
    series: [{ name: `${info.name}(${info.unit})`, type: 'line', showSymbol: periods.length < 40, connectNulls: false, itemStyle: { color: info.color }, data: periods.map(period => values.get(period) ?? null) }],
  });
  const usage = data.value.areas.filter(row => row.energyType === selectedEnergy.value).sort((a, b) => Number(b.quantity) - Number(a.quantity)).slice(0, 10).reverse();
  await usageChart.renderEcharts({
    animation: false, toolbox: toolbox(`${info.name}区域用量`),
    tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
    grid: { left: 16, right: 36, top: 40, bottom: 24, containLabel: true },
    xAxis: { type: 'value', name: info.unit },
    yAxis: { type: 'category', data: usage.map(row => row.label), axisLabel: { width: 100, overflow: 'truncate' } },
    series: [{ name: `${info.name}(${info.unit})`, type: 'bar', itemStyle: { color: info.color }, data: usage.map(row => Number(row.quantity)) }],
  });
  await costChart.renderEcharts({
    animation: false, toolbox: toolbox('能源费用趋势'),
    tooltip: { trigger: 'axis', confine: true }, legend: { bottom: 0 },
    grid: { left: 16, right: 24, top: 40, bottom: 70, containLabel: true },
    xAxis: { type: 'category', data: periods }, yAxis: { type: 'value', name: '费用(元)' },
    dataZoom: [{ type: 'inside' }],
    series: energyOptions.map(option => {
      const costs = new Map(data.value!.trend.filter(row => row.energyType === option.value).map(row => [row.label, Number(row.amount)]));
      return { name: option.label, type: 'line' as const, showSymbol: periods.length < 40, connectNulls: false,
        itemStyle: { color: energyInfo[option.value as EnergyType].color }, data: periods.map(period => costs.get(period) ?? null) };
    }),
  });
}
async function renderCharts() {
  if (!hasRecords.value || !data.value) return;
  await renderTrend();
  if (Number(data.value.totalAmount) > 0) await shareChart.renderEcharts({
    animation: false,
    toolbox: toolbox('能源费用占比'),
    tooltip: { trigger: 'item', confine: true, formatter: '{b}:{c} å…ƒï¼ˆ{d}%)' },
    legend: { bottom: 0 },
    series: [{ type: 'pie', radius: '65%', center: ['50%', '45%'], label: { formatter: '{b}\n{d}%' }, data: data.value.summary.filter(row => Number(row.amount) > 0).map(row => ({ name: energyInfo[row.energyType].name, value: Number(row.amount), itemStyle: { color: energyInfo[row.energyType].color } })) }],
  });
  const totals = new Map<string, number>();
  for (const row of data.value.areas) totals.set(row.label, (totals.get(row.label) ?? 0) + Number(row.amount));
  if (Number(data.value.totalAmount) > 0) {
    const sorted = [...totals].filter(([, amount]) => amount > 0).sort((a, b) => b[1] - a[1]);
    const shares = sorted.slice(0, 10).map(([name, value]) => ({ name, value }));
    const others = sorted.slice(10).reduce((sum, [, amount]) => sum + amount, 0);
    if (others > 0) shares.push({ name: '其他区域合计', value: others });
    await areaShareChart.renderEcharts({
      animation: false, toolbox: toolbox('区域费用占比'),
      tooltip: { trigger: 'item', confine: true, formatter: '{b}:{c} å…ƒï¼ˆ{d}%)' },
      legend: { type: 'scroll', bottom: 0 },
      series: [{ type: 'pie', radius: ['35%', '60%'], center: ['50%', '44%'], label: { formatter: '{b}\n{d}%', width: 85, overflow: 'truncate' }, data: shares }],
    });
  }
  const areaLabels = [...totals].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([label]) => label).reverse();
  await areaChart.renderEcharts({
    animation: false,
    toolbox: toolbox('区域费用分布'),
    tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
    legend: { bottom: 0 },
    grid: { left: 16, right: 24, top: 30, bottom: 45, containLabel: true },
    xAxis: { type: 'value', name: '元' },
    yAxis: { type: 'category', data: areaLabels, axisLabel: { width: 110, overflow: 'truncate' } },
    series: energyOptions.map(option => ({
      name: option.label, type: 'bar' as const, stack: 'amount', itemStyle: { color: energyInfo[option.value as EnergyType].color },
      data: areaLabels.map(label => data.value!.areas.filter(row => row.label === label && row.energyType === option.value).reduce((sum, row) => sum + Number(row.amount), 0)),
    })),
  });
}
watch(selectedEnergy, () => { void renderTrend(); });
onMounted(() => load());
</script>
<template>
  <Page title="能耗数据分析" description="基于手动录入的台账,查看用量趋势及费用分布。">
    <Card class="mb-4">
      <Form :model="filters" layout="inline" class="analysis-filters" @finish="load">
        <FormItem label="开始日期"><DatePicker v-model:value="filters.startDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
        <FormItem label="结束日期"><DatePicker v-model:value="filters.endDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
        <FormItem label="汇总"><Select v-model:value="filters.groupBy" :options="[{ label: '按日', value: 'DAY' }, { label: '按月', value: 'MONTH' }]" style="width: 100px" /></FormItem>
        <FormItem label="能源"><Select v-model:value="filters.energyType" :options="energyOptions" allow-clear placeholder="全部能源" style="width: 130px" /></FormItem>
        <FormItem label="区域"><Input v-model:value="filters.usageArea" placeholder="区域关键词" allow-clear style="width: 150px" /></FormItem>
        <FormItem label="产品"><Select v-model:value="filters.productCategory" :options="productOptions" allow-clear placeholder="全部类别" style="width: 150px" /></FormItem>
        <FormItem><Space wrap><Button type="primary" :loading="loading" @click="load">分析</Button><Button @click="reset">重置</Button><Button v-access:code="['energy:analysis:export']" :disabled="!data || loading" :loading="exporting" @click="exportReport">导出分析报表</Button></Space></FormItem>
      </Form>
    </Card>
    <Alert v-if="failed" type="error" show-icon message="能耗分析加载失败,请点击分析重试。" class="mb-4" />
    <Spin :spinning="loading">
      <template v-if="data">
        <Card class="mb-4" title="能源汇总">
          <template #extra>{{ applied.startDate }} è‡³ {{ applied.endDate }}</template>
          <div class="mb-4 flex flex-wrap gap-x-6 gap-y-2">
            <span>总费用:<strong>{{ numberText(data.totalAmount, 2) }} å…ƒ</strong></span><span>已录入 {{ data.recordCount }} æ¡</span>
          </div>
          <Table :columns="summaryColumns" :data-source="data.summary" row-key="energyType" :pagination="false" :scroll="{ x: 600 }" size="small">
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
              <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
              <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
              <template v-else-if="column.key === 'share'">{{ Number(data.totalAmount) > 0 ? numberText(Number(record.amount) / Number(data.totalAmount) * 100, 2) + '%' : '—' }}</template>
            </template>
          </Table>
          <p class="mb-0 mt-3 text-sm text-muted-foreground">水、电、蒸汽按各自单位统计。未录入日期在趋势中留空;单价为 0 çš„记录仍计入用量。</p>
        </Card>
        <Card v-if="!hasRecords"><Empty description="所选范围暂无能耗记录,请调整筛选条件或先录入能耗信息。" /></Card>
        <template v-else>
          <Card class="mb-4" title="用量趋势">
            <template #extra><RadioGroup v-model:value="selectedEnergy" :options="energyOptions" option-type="button" size="small" /></template>
            <EchartsUI ref="trendRef" class="h-[340px] w-full" />
          </Card>
          <div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
            <Card title="能源费用占比"><EchartsUI v-if="Number(data.totalAmount) > 0" ref="shareRef" class="h-[330px] w-full" /><Empty v-else description="尚未录入费用,填写单价后显示费用占比。" /></Card>
            <Card title="区域费用分布(前 10)"><EchartsUI ref="areaRef" class="h-[330px] w-full" /></Card>
            <Card title="能源费用趋势"><EchartsUI ref="costRef" class="h-[330px] w-full" /></Card>
            <Card title="区域费用占比"><EchartsUI v-if="Number(data.totalAmount) > 0" ref="areaShareRef" class="h-[330px] w-full" /><Empty v-else description="尚无费用数据" /></Card>
            <Card class="lg:col-span-2" :title="`${energyInfo[selectedEnergy].name} Â· åŒºåŸŸç”¨é‡å¯¹æ¯”(前 10)`"><EchartsUI ref="usageRef" class="h-[330px] w-full" /></Card>
          </div>
          <p class="text-sm text-muted-foreground">图表右上角可下载 PNG;分析报表包含统计口径、能源汇总、日期趋势、区域汇总四个工作表,使用最近一次分析的筛选条件。</p>
          <Card title="趋势明细">
            <Table :columns="detailColumns" :data-source="data.trend" :row-key="row => `${row.label}-${row.energyType}`" :scroll="{ x: 600 }" :pagination="{ pageSize: 10, showSizeChanger: true }" size="small">
              <template #bodyCell="{ column, record }">
                <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
                <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
                <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
              </template>
            </Table>
          </Card>
        </template>
      </template>
    </Spin>
  </Page>
</template>
<style scoped>
.analysis-filters { gap: 8px 0; }
</style>
src/views/energy/record/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { FormInstance, TablePaginationConfig } from 'ant-design-vue';
import type { EnergyFilters, EnergyRecord } from '#/api/energy';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Alert, AutoComplete, Button, Card, DatePicker, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { createEnergyRecord, deleteEnergyRecord, exportEnergyRecords, getEnergyRecord, getEnergyRecordPage, updateEnergyRecord } from '#/api/energy';
import { areaOptions, energyInfo, energyOptions, numberText, productOptions, purposeOptions } from '../shared';
defineOptions({ name: 'EnergyRecord' });
const filters = reactive<EnergyFilters>({});
const rows = ref<EnergyRecord[]>([]);
const loading = ref(false);
const exporting = ref(false);
async function exportRecords() {
  if (filters.startDate && filters.endDate && filters.startDate > filters.endDate) { message.warning('开始日期不能晚于结束日期'); return; }
  exporting.value = true;
  try { downloadFileFromBlobPart({ fileName: '能耗台账.xlsx', source: await exportEnergyRecords({ ...filters }) }); }
  finally { exporting.value = false; }
}
const loadError = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const open = ref(false);
const saving = ref(false);
const detail = ref(false);
const formRef = ref<FormInstance>();
const initial = (): EnergyRecord => ({ recordDate: dayjs().format('YYYY-MM-DD'), energyType: 'WATER', usageArea: '', productCategory: '燕窝', usagePurpose: '', quantity: 1, unitPrice: 0, recorder: '', remark: '' });
const model = reactive<EnergyRecord>(initial());
const unit = computed(() => energyInfo[model.energyType].unit);
const estimatedAmount = computed(() => numberText(Number(model.quantity || 0) * Number(model.unitPrice || 0), 2));
const title = computed(() => detail.value ? '能耗记录详情' : model.id ? '编辑能耗记录' : '新增能耗记录');
const rules = {
  recordDate: [{ required: true, message: '请选择统计日期' }],
  energyType: [{ required: true, message: '请选择能源类型' }],
  usageArea: [{ required: true, whitespace: true, message: '请输入使用区域' }],
  productCategory: [{ required: true, message: '请选择产品类别' }],
  usagePurpose: [{ required: true, whitespace: true, message: '请输入用途' }],
  quantity: [{ required: true, type: 'number' as const, min: 0.001, message: '用量必须大于零' }],
  unitPrice: [{ required: true, type: 'number' as const, min: 0, message: '单价不能为负数' }],
  recorder: [{ required: true, whitespace: true, message: '请输入录入人' }],
};
const columns = [
  { title: '统计日期', dataIndex: 'recordDate', width: 120 },
  { title: '能源', dataIndex: 'energyType', width: 95 },
  { title: '使用区域', dataIndex: 'usageArea', width: 160 },
  { title: '产品类别', dataIndex: 'productCategory', width: 130 },
  { title: '用途', dataIndex: 'usagePurpose', width: 170 },
  { title: '用量', dataIndex: 'quantity', width: 150, align: 'right' as const },
  { title: '单价(元/单位)', dataIndex: 'unitPrice', width: 150, align: 'right' as const },
  { title: '费用(元)', dataIndex: 'amount', width: 145, align: 'right' as const },
  { title: '录入人', dataIndex: 'recorder', width: 110 },
  { title: '操作', key: 'action', width: 190, fixed: 'right' as const },
];
let requestId = 0;
async function load(reset = false) {
  if (filters.startDate && filters.endDate && filters.startDate > filters.endDate) {
    message.warning('开始日期不能晚于结束日期'); return;
  }
  if (reset) pagination.current = 1;
  const currentId = ++requestId;
  loading.value = true;
  loadError.value = false;
  try {
    const data = await getEnergyRecordPage({ ...filters, pageNo: pagination.current, pageSize: pagination.pageSize });
    if (currentId !== requestId) return;
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    if (currentId === requestId) { rows.value = []; loadError.value = true; }
  } finally { if (currentId === requestId) loading.value = false; }
}
function resetFilters() {
  Object.keys(filters).forEach(key => delete filters[key as keyof EnergyFilters]);
  void load(true);
}
function tableChange(page: TablePaginationConfig) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
async function showForm(row?: EnergyRecord, readonly = false) {
  const values = row ? await getEnergyRecord(row.id!) : initial();
  detail.value = readonly;
  // æ¸…除前一次编辑的编号及详情字段。
  Object.keys(model).forEach(key => delete (model as unknown as Record<string, unknown>)[key]);
  Object.assign(model, values);
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  saving.value = true;
  try {
    const data = { ...model, usageArea: model.usageArea.trim(), usagePurpose: model.usagePurpose.trim(), recorder: model.recorder.trim() };
    await (data.id ? updateEnergyRecord(data) : createEnergyRecord(data));
    message.success('能耗记录已保存');
    open.value = false;
    await load();
  } finally { saving.value = false; }
}
const deletingId = ref<number>();
async function remove(row: EnergyRecord) {
  deletingId.value = row.id;
  try {
    await deleteEnergyRecord(row.id!);
    message.success('能耗记录已删除');
    if (rows.value.length === 1 && pagination.current > 1) pagination.current--;
    await load();
  } finally { deletingId.value = undefined; }
}
onMounted(() => load());
</script>
<template>
  <Page title="能耗记录" description="按天手动记录水、电、蒸汽使用情况,费用自动计算。">
    <Card>
      <Form :model="filters" layout="inline" class="energy-filters" @finish="load(true)">
        <FormItem label="开始日期"><DatePicker v-model:value="filters.startDate" value-format="YYYY-MM-DD" placeholder="开始日期" /></FormItem>
        <FormItem label="结束日期"><DatePicker v-model:value="filters.endDate" value-format="YYYY-MM-DD" placeholder="结束日期" /></FormItem>
        <FormItem label="能源"><Select v-model:value="filters.energyType" :options="energyOptions" allow-clear placeholder="全部能源" style="width: 130px" /></FormItem>
        <FormItem label="区域"><Input v-model:value="filters.usageArea" allow-clear placeholder="输入区域关键词" style="width: 160px" /></FormItem>
        <FormItem label="产品"><Select v-model:value="filters.productCategory" :options="productOptions" allow-clear placeholder="全部类别" style="width: 150px" /></FormItem>
        <FormItem><Space><Button type="primary" :loading="loading" @click="load(true)">查询</Button><Button @click="resetFilters">重置</Button></Space></FormItem>
      </Form>
      <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
        <span class="text-base font-semibold">能耗台账</span>
        <Space><Button v-access:code="['energy:record:export']" :loading="exporting" @click="exportRecords">导出台账</Button><Button v-access:code="['energy:record:create']" type="primary" @click="showForm()">新增能耗记录</Button></Space>
      </div>
      <Alert v-if="loadError" type="error" message="能耗记录加载失败,请点击查询重试。" show-icon class="mb-4" />
      <Table :columns="columns" :data-source="rows" row-key="id" :loading="loading" :pagination="pagination" :scroll="{ x: 1420 }" :locale="{ emptyText: '暂无能耗记录,请新增每日用能信息' }" @change="tableChange">
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'energyType'"><Tag>{{ energyInfo[record.energyType as keyof typeof energyInfo].name }}</Tag></template>
          <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as keyof typeof energyInfo].unit }}</template>
          <template v-else-if="column.dataIndex === 'unitPrice'">{{ numberText(record.unitPrice, 4) }}</template>
          <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button v-access:code="['energy:record:query']" type="link" size="small" @click="showForm(record as EnergyRecord, true)">详情</Button>
              <Button v-access:code="['energy:record:update']" type="link" size="small" @click="showForm(record as EnergyRecord)">编辑</Button>
              <Popconfirm title="确定删除这条能耗记录?删除后统计数据也会更新。" ok-text="确定" cancel-text="取消" @confirm="remove(record as EnergyRecord)">
                <Button v-access:code="['energy:record:delete']" type="link" danger size="small" :loading="deletingId === record.id">删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal v-model:open="open" :title="title" :width="760" :confirm-loading="saving" :closable="!saving" :mask-closable="!saving" :keyboard="!saving" :cancel-button-props="{ disabled: saving }" :footer="detail ? null : undefined" ok-text="保存" cancel-text="取消" @ok="save">
      <Form ref="formRef" :model="model" :rules="rules" :disabled="detail || saving" layout="vertical" class="energy-form">
        <FormItem label="统计日期" name="recordDate"><DatePicker v-model:value="model.recordDate" value-format="YYYY-MM-DD" :disabled-date="date => date.isAfter(dayjs(), 'day')" class="w-full" /></FormItem>
        <FormItem label="能源类型" name="energyType"><Select v-model:value="model.energyType" :options="energyOptions" /></FormItem>
        <FormItem label="使用区域" name="usageArea"><AutoComplete v-model:value="model.usageArea" :options="areaOptions" :maxlength="100" placeholder="选择建议区域或手动输入" /></FormItem>
        <FormItem label="产品类别" name="productCategory"><Select v-model:value="model.productCategory" :options="productOptions" /></FormItem>
        <FormItem label="用途 / å·¥åº" name="usagePurpose"><AutoComplete v-model:value="model.usagePurpose" :options="purposeOptions" :maxlength="100" placeholder="如设备清洗、蒸煮提取、灭菌" /></FormItem>
        <FormItem label="录入人" name="recorder"><Input v-model:value="model.recorder" :maxlength="50" placeholder="请输入姓名" /></FormItem>
        <FormItem :label="`用量(${unit})`" name="quantity"><InputNumber v-model:value="model.quantity" :min="0.001" :max="999999999999999.999" :precision="3" style="width: 100%" /></FormItem>
        <FormItem :label="`单价(元/${unit})`" name="unitPrice"><InputNumber v-model:value="model.unitPrice" :min="0" :max="99999999.9999" :precision="4" style="width: 100%" /></FormItem>
        <div class="col-span-full mb-4 rounded border border-solid border-[hsl(var(--border))] p-3">
          è´¹ç”¨ï¼š<strong>{{ detail ? numberText(model.amount, 2) : estimatedAmount }} å…ƒ</strong>
          <div class="mt-1 text-sm text-muted-foreground">按用量 Ã— å•价计算,保存时精确到分。仅记录用量时,单价可填 0。</div>
        </div>
        <FormItem label="备注" name="remark" class="col-span-full"><Input.TextArea v-model:value="model.remark" :maxlength="500" show-count :rows="3" placeholder="如计量口径、特殊用能情况等" /></FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.energy-filters { gap: 8px 0; margin-bottom: 24px; }
.energy-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 20px; }
@media (max-width: 640px) { .energy-form { grid-template-columns: minmax(0, 1fr); } }
</style>
src/views/energy/shared.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
import type { EnergyType } from '#/api/energy';
export const energyOptions = [
  { label: 'æ°´', value: 'WATER' },
  { label: '电', value: 'ELECTRICITY' },
  { label: '蒸汽', value: 'STEAM' },
];
export const energyInfo: Record<EnergyType, { name: string; unit: string; color: string }> = {
  WATER: { name: 'æ°´', unit: 'm³', color: '#1677ff' },
  ELECTRICITY: { name: '电', unit: 'kWh', color: '#d48806' },
  STEAM: { name: '蒸汽', unit: 't', color: '#389e0d' },
};
export const productOptions = ['燕窝', '滋补品原浆', '公用能耗', '其他'].map(value => ({ label: value, value }));
export const areaOptions = ['燕窝生产区', '原浆生产区', '清洗区', '灭菌区', '灌装包装区', '动力站', '仓储区', '办公生活区'].map(value => ({ value }));
export const purposeOptions = ['原料清洗', '设备清洗 / CIP', '蒸煮提取', '灭菌', '灌装包装', '制冷', '采暖', '日常用能'].map(value => ({ value }));
export function numberText(value: number | undefined, digits = 3) {
  return Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits === 2 ? 2 : 0 });
}
src/views/erp/finance/account/data.ts
@@ -179,7 +179,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/erp/finance/payment/data.ts
@@ -41,8 +41,8 @@
      componentProps: {
        disabled: formType === 'detail',
        placeholder: '选择付款时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/finance/payment/detail/approve/index.vue
@@ -7,7 +7,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -94,7 +94,7 @@
    {
      label: '付款时间',
      field: 'paymentTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合计金额(元)',
@@ -123,7 +123,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/erp/finance/payment/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitFinancePayment } from '#/api/erp/finance/payment';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpFinancePaymentApi } from '#/api/erp/finance/payment';
@@ -77,10 +79,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: ErpFinancePaymentApi.FinancePayment) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitFinancePayment(row.id!, 'finance_payment_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getFinancePaymentApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -88,6 +97,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
src/views/erp/finance/receipt/data.ts
@@ -40,8 +40,8 @@
      componentProps: {
        disabled: formType === 'detail',
        placeholder: '选择收款时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/product/category/data.ts
@@ -138,7 +138,7 @@
    {
      field: 'createTime',
      title: '创建时间',
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/erp/product/product/data.ts
@@ -243,7 +243,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/erp/product/unit/data.ts
@@ -91,7 +91,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/erp/purchase/in/data.ts
@@ -45,8 +45,8 @@
      componentProps: {
        disabled: formType === 'detail',
        placeholder: '选择入库时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/purchase/invoice/data.ts
@@ -225,7 +225,7 @@
      title: '来票日期',
      field: 'invoiceTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '备注',
src/views/erp/purchase/invoice/detail/approve/index.vue
@@ -5,7 +5,7 @@
import { onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -50,13 +50,13 @@
    {
      label: '开票日期',
      field: 'invoiceTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/erp/purchase/invoice/index.vue
@@ -4,10 +4,11 @@
import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { Page, useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tag } from 'ant-design-vue';
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -84,6 +85,34 @@
  }
}
/** å·¥å…·æ æŒ‰é’®ï¼šè¯†åˆ«å½•入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['来票']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['erp:purchase-invoice:create'],
    onClick: handleCreate,
  },
  ...(isAiEnable()
    ? [
        {
          label: '识别录入',
          type: 'primary' as const,
          auth: ['erp:purchase-invoice:create'],
          onClick: handleOcrCreate,
        },
      ]
    : []),
  {
    label: $t('ui.actionTitle.export'),
    type: 'primary' as const,
    icon: ACTION_ICON.DOWNLOAD,
    auth: ['erp:purchase-invoice:export'],
    onClick: handleExport,
  },
]);
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -121,30 +150,7 @@
    <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,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #no="{ row }">
        <Button type="link" @click="handleDetail(row)">
src/views/erp/purchase/order/data.ts
@@ -43,8 +43,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择订单时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/purchase/order/detail/index.vue
@@ -6,11 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
} from '@vben/utils';
import { erpCountInputFormatter, erpPriceInputFormatter, formatDate } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
@@ -52,7 +48,7 @@
    {
      label: '订单时间',
      field: 'orderTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合计数量',
@@ -89,7 +85,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/erp/purchase/request/data.ts
@@ -46,8 +46,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择申请时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        disabled: formType === 'detail',
      },
src/views/erp/purchase/request/detail/index.vue
@@ -7,11 +7,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
} from '@vben/utils';
import { erpCountInputFormatter, erpPriceInputFormatter, formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -114,7 +110,7 @@
    {
      label: '申请时间',
      field: 'requestTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '供应商名称', field: 'supplierName', render: textRender },
    {
@@ -132,7 +128,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/erp/purchase/request/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseRequestApi } from '#/api/erp/purchase/request';
@@ -96,10 +97,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: ErpPurchaseRequestApi.PurchaseRequest) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitPurchaseRequest(row.id!, 'purchase_request_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getApproveProcessList(row.id!);
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -107,6 +115,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
src/views/erp/purchase/return/data.ts
@@ -45,8 +45,8 @@
      componentProps: {
        disabled: formType === 'detail',
        placeholder: '选择退货时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/sale/customer/data.ts
@@ -229,7 +229,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/erp/sale/order/data.ts
@@ -70,8 +70,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择订单时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
@@ -341,6 +341,8 @@
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        showTime: false,
        format: 'YYYY-MM-DD',
        allowClear: true,
      },
    },
src/views/erp/sale/order/detail/index.vue
@@ -9,7 +9,7 @@
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
  formatDate,
} from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
@@ -67,7 +67,7 @@
    {
      label: '订单时间',
      field: 'orderTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合计数量',
@@ -109,7 +109,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/erp/sale/order/index.vue
@@ -9,6 +9,7 @@
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message, Tag, Tooltip } from 'ant-design-vue';
import dayjs from 'dayjs';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -22,6 +23,7 @@
} from '#/api/erp/sale/order';
import { generateFromSaleOrder } from '#/api/mes/wm/salesnotice';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -57,8 +59,25 @@
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportSaleOrder(await gridApi.formApi.getValues());
  const data = await exportSaleOrder(
    normalizeDateRange(await gridApi.formApi.getValues()),
  );
  downloadFileFromBlobPart({ fileName: '销售订单.xls', source: data });
}
/** æ—¥æœŸç­›é€‰è¦†ç›–所选起止日期的全天,兼容后端时间类型。 */
function normalizeDateRange(values: Record<string, any>) {
  const range = values.orderTime;
  if (!Array.isArray(range) || range.length !== 2 || !range.every(Boolean)) {
    return values;
  }
  return {
    ...values,
    orderTime: [
      dayjs(range[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
      dayjs(range[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss'),
    ],
  };
}
/** å¯¼å…¥é”€å”®è®¢å• */
@@ -94,18 +113,18 @@
/** æäº¤é”€å”®è®¢å•审批(协同办公) */
async function handleSubmit(row: ErpSaleOrderApi.SaleOrder) {
  // èŽ·å–å¯ç”¨çš„å®¡æ‰¹æµç¨‹å®šä¹‰
  const processList = await getSaleOrderApproveProcessList();
  if (isEmpty(processList)) {
  const processList = automaticApproval.value ? [] : await getSaleOrderApproveProcessList();
  if (!automaticApproval.value && isEmpty(processList)) {
    message.error('销售订单审批流程未配置,请先在「协同办公→流程管理」中创建并发布销售订单审批流程');
    return;
  }
  const hideLoading = message.loading({
    content: '正在提交审批...',
    content: automaticApproval.value ? '正在提交生效...' : '正在提交审批...',
    duration: 0,
  });
  try {
    await submitSaleOrder(row.id!, processList[0].key);
    message.success('提交审批成功,请到协同办公中处理');
    await submitSaleOrder(row.id!, automaticApproval.value ? 'sale_order_approve' : processList[0]!.key);
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功,请到协同办公中处理');
    handleRefresh();
  } finally {
    hideLoading();
@@ -208,7 +227,7 @@
      ajax: {
        query: async ({ page }, formValues) => {
          // ç”Ÿäº§çŠ¶æ€æœç´¢å‚æ•°è½¬æ¢ï¼š-1(无需生产) â†’ needProduction=0
          const { productionStatus, ...rest } = formValues;
          const { productionStatus, ...rest } = normalizeDateRange(formValues);
          const params: Record<string, any> = { ...rest };
          if (productionStatus === -1) {
            params.needProduction = 0;
src/views/erp/sale/order/modules/form.vue
@@ -6,6 +6,7 @@
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDate } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
@@ -133,7 +134,10 @@
    try {
      formData.value = await getSaleOrder(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await formApi.setValues({
        ...formData.value,
        productionFinishTime: formatDate(formData.value.productionFinishTime),
      });
      if (formData.value?.attachmentList?.length) {
        const blobIds = formData.value.attachmentList.map((item) => ({
          uid: String(item.id),
src/views/erp/sale/order/modules/print.vue
@@ -7,7 +7,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { erpPriceMultiply, formatDateTime } from '@vben/utils';
import { erpPriceMultiply, formatDate, formatDateTime } from '@vben/utils';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getAccountSimpleList } from '#/api/erp/finance/account';
@@ -216,7 +216,7 @@
        <div>订单单号:{{ printData.no || '-' }}</div>
        <div>
          è®¢å•时间:{{
            formatDateTime(printData.orderTime) || '-'
            formatDate(printData.orderTime) || '-'
          }}
        </div>
        <div>客户:{{ customerName }}</div>
@@ -229,7 +229,7 @@
        <div>创建人:{{ creatorName }}</div>
        <div>
          åˆ›å»ºæ—¶é—´ï¼š{{
            formatDateTime(printData.createTime) || '-'
            formatDate(printData.createTime) || '-'
          }}
        </div>
        <div class="col-span-3">备注:{{ printData.remark || '-' }}</div>
src/views/erp/sale/out/data.ts
@@ -45,8 +45,8 @@
      componentProps: {
        disabled: formType === 'detail',
        placeholder: '选择出库时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/sale/return/data.ts
@@ -45,8 +45,8 @@
      componentProps: {
        disabled: formType === 'detail',
        placeholder: '选择退货时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/stock/check/data.ts
@@ -40,8 +40,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择盘点时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/stock/in/data.ts
@@ -41,8 +41,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择入库时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/stock/move/data.ts
@@ -40,8 +40,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择调度时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/stock/out/data.ts
@@ -41,8 +41,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择出库时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
src/views/erp/stock/record/data.ts
@@ -107,7 +107,7 @@
      field: 'createTime',
      title: '出入库日期',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'count',
src/views/erp/stock/warehouse/data.ts
@@ -196,7 +196,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/erp/stock/warehouseMapping/data.ts
@@ -84,7 +84,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/attendance/exception/data.ts
@@ -129,7 +129,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/attendance/exception/detail/approve/index.vue
@@ -6,7 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDateTime, formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -49,7 +49,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ] as DescriptionItemSchema[],
});
src/views/hrm/attendance/exception/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmAttendanceExceptionApi } from '#/api/hrm/attendance/exception';
@@ -48,10 +49,15 @@
/** æäº¤è€ƒå‹¤å¼‚常申请 */
async function handleSubmit(row: HrmAttendanceExceptionApi.AttendanceException) {
  const res = await getAttendanceExceptionApproveProcessList();
  const res = automaticApproval.value ? [] : await getAttendanceExceptionApproveProcessList();
  processList.value = res;
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'hrm_attendance_exception';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
@@ -64,7 +70,7 @@
  submitLoading.value = true;
  try {
    await submitAttendanceException(currentRow.value!.id!, selectedProcessKey.value);
    message.success('提交审批成功');
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
src/views/hrm/attendance/holiday/data.ts
@@ -155,7 +155,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/attendance/record/modules/import-form.vue
@@ -31,7 +31,8 @@
      emit('success');
      // åŽç«¯è¿”回导入结果(含失败原因,例如“以下员工不存在...;需补充手机号...”)
      if (res && res.length > 0) {
        message[res.includes('未') || res.includes('不完整') ? 'warning' : 'success'](res);
        // å¤±è´¥æ—¶åŽç«¯ä¼šæ‹¼ä¸Šã€Œæœªèƒ½å¯¼å…¥ï¼šã€æ˜Žç»†æ®µï¼Œç”¨å®ƒåˆ¤å®šï¼Œé¿å…æˆåŠŸæ–‡æ¡ˆé‡Œå‡ºçŽ°ã€Œæœªã€å°±è¯¯æŠ¥è­¦å‘Š
        message[res.includes('未能导入') ? 'warning' : 'success'](res);
      } else {
        message.success('导入成功');
      }
src/views/hrm/employee/contract/data.ts
@@ -285,7 +285,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/employee/data.ts
@@ -385,7 +385,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/leave/data.ts
@@ -173,7 +173,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/leave/detail/approve/index.vue
@@ -6,7 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDateTime, formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -60,7 +60,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ] as DescriptionItemSchema[],
});
src/views/hrm/leave/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmLeaveApi } from '#/api/hrm/leave';
@@ -72,9 +73,14 @@
/** æäº¤è¯·å‡ç”³è¯·ï¼ˆå…ˆé€‰æ‹©å®¡æ‰¹æµç¨‹ï¼‰ */
async function handleSubmit(row: HrmLeaveApi.Leave) {
  processList.value = await getLeaveApproveProcessDefinitionList();
  processList.value = automaticApproval.value ? [] : await getLeaveApproveProcessDefinitionList();
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'hrm_leave';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
src/views/hrm/overtime/data.ts
@@ -308,7 +308,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/overtime/detail/approve/index.vue
@@ -6,7 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
@@ -54,7 +54,7 @@
      field: 'status',
      render: (value) => h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value }),
    },
    { label: '创建时间', field: 'createTime', render: (value) => formatDateTime(value) as string },
    { label: '创建时间', field: 'createTime', render: (value) => formatDate(value) as string },
  ] as DescriptionItemSchema[],
});
src/views/hrm/overtime/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmOvertimeApi } from '#/api/hrm/overtime';
@@ -64,9 +65,14 @@
const submitLoading = ref(false);
async function handleSubmit(row: HrmOvertimeApi.Overtime) {
  processList.value = await getOvertimeApproveProcessDefinitionList();
  processList.value = automaticApproval.value ? [] : await getOvertimeApproveProcessDefinitionList();
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'hrm_overtime';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
@@ -78,7 +84,7 @@
  submitLoading.value = true;
  try {
    await submitOvertime(currentRow.value!.id!, selectedProcessKey.value);
    message.success('已提交至协同办公审批');
    message.success(automaticApproval.value ? '已提交生效' : '已提交至协同办公审批');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
src/views/hrm/resignation/data.ts
@@ -157,7 +157,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/resignation/detail/approve/index.vue
@@ -6,7 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -42,7 +42,7 @@
    {
      label: '预计离职日期',
      field: 'expectedDate',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '离职原因', field: 'reason', render: textRender },
    { label: '交接人', field: 'handoverUserName', render: textRender },
@@ -62,7 +62,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ] as DescriptionItemSchema[],
});
src/views/hrm/resignation/index.vue
@@ -163,7 +163,7 @@
            {
              label: '交接',
              type: 'link',
              auth: ['hrm:resignation:handover'],
              auth: ['hrm:resignation:update'],
              ifShow: row.status === 20 && row.handoverStatus !== 20,
              popConfirm: {
                title: row.handoverStatus === 0 ? '确认开始交接吗?' : '确认完成交接吗?',
src/views/hrm/salary/employee-social-security/data.ts
@@ -166,7 +166,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/salary/leave-type-config/data.ts
@@ -106,7 +106,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/salary/payment/data.ts
@@ -65,7 +65,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/salary/social-security-scheme/data.ts
@@ -316,7 +316,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/salary/structure/data.ts
@@ -150,7 +150,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'actions',
src/views/hrm/transfer/data.ts
@@ -203,7 +203,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/hrm/transfer/detail/approve/index.vue
@@ -6,7 +6,7 @@
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -44,7 +44,7 @@
    {
      label: '生效日期',
      field: 'effectiveDate',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '调岗原因', field: 'reason', render: textRender },
    {
@@ -57,7 +57,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ] as DescriptionItemSchema[],
});
src/views/im/home/store/rtcStore.ts
@@ -422,7 +422,13 @@
    })
  }
  function $reset() {
    reset()
    clearGroupCallCache()
  }
  return {
    $reset,
    stage,
    call,
    incomingPayload,
src/views/im/home/store/uiStore.ts
@@ -129,7 +129,30 @@
    nextUnreadJumpNonce.value++
  }
  function $reset() {
    Object.assign(userInfoCard, {
      show: false,
      user: null,
      position: { x: 0, y: 0 },
      addSource: ImFriendAddSource.SEARCH,
      addSourceExtra: ''
    })
    Object.assign(groupInfoCard, {
      show: false,
      group: null,
      position: { x: 0, y: 0 }
    })
    Object.assign(contextMenu, {
      show: false,
      position: { x: 0, y: 0 },
      items: [],
      onSelect: null
    })
    nextUnreadJumpNonce.value = 0
  }
  return {
    $reset,
    userInfoCard,
    openUserInfoCard,
    openUserInfoCardAtEvent,
src/views/mes/cal/plan/data.ts
@@ -289,7 +289,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/cal/team/data.ts
@@ -151,7 +151,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/checkplan/data.ts
@@ -255,7 +255,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/checkrecord/data.ts
@@ -76,9 +76,9 @@
      label: '点检时间',
      component: 'DatePicker',
      componentProps: {
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        placeholder: '请选择点检时间',
        showTime: true,
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
@@ -146,7 +146,7 @@
      field: 'checkTime',
      title: '点检时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    { field: 'nickname', title: '点检人', minWidth: 120 },
    {
@@ -162,7 +162,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/machinery/data.ts
@@ -150,8 +150,8 @@
      componentProps: {
        class: '!w-full',
        disabled: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: true,
        format: 'YYYY-MM-DD',
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      dependencies: {
@@ -166,8 +166,8 @@
      componentProps: {
        class: '!w-full',
        disabled: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: true,
        format: 'YYYY-MM-DD',
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      dependencies: {
@@ -262,19 +262,19 @@
      field: 'lastCheckTime',
      title: '最近点检时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'lastMaintenTime',
      title: '最近保养时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -368,7 +368,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
  ];
}
src/views/mes/dv/machinery/modules/check-record-list.vue
@@ -28,7 +28,7 @@
        field: 'checkTime',
        title: '点检时间',
        width: 180,
        formatter: 'formatDateTime',
        formatter: 'formatDate',
      },
      { field: 'nickname', title: '操作人', minWidth: 120 },
      {
src/views/mes/dv/machinery/modules/import-form.vue
@@ -6,7 +6,7 @@
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Upload } from 'ant-design-vue';
import { Button, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -14,6 +14,7 @@
  importMachineryTemplate,
} from '#/api/mes/dv/machinery';
import { $t } from '#/locales';
import { notifyImportResult } from '#/utils';
import { useImportFormSchema } from '../data';
@@ -44,14 +45,21 @@
    try {
      const result = await importMachinery(data.file, data.updateSupport);
      const importData = result as MesDvMachineryApi.MachineryImportRespVO;
      let text = `上传成功数量:${importData.createCodes?.length || 0};`;
      text += `更新成功数量:${importData.updateCodes?.length || 0};`;
      text += `更新失败数量:${Object.keys(importData.failureCodes || {}).length};`;
      message.info(text);
      // å…³é—­å¹¶æç¤º
      const createCount = importData.createCodes?.length || 0;
      const updateCount = importData.updateCodes?.length || 0;
      // å…³é—­å¹¶æŒ‰å¯¼å…¥ç»“果提示:仅在确有数据写入时通知列表刷新
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
      if (createCount + updateCount > 0) {
        emit('success');
      }
      notifyImportResult({
        subject: '设备',
        createCount,
        updateCount,
        failures: importData.failureCodes,
        masterDataHint: '【设备类型】【车间】【工作站】中的对应编码',
      });
    } finally {
      modalApi.unlock();
    }
src/views/mes/dv/machinery/modules/mainten-record-list.vue
@@ -28,7 +28,7 @@
        field: 'maintenTime',
        title: '保养时间',
        width: 180,
        formatter: 'formatDateTime',
        formatter: 'formatDate',
      },
      { field: 'nickname', title: '操作人', minWidth: 120 },
      {
src/views/mes/dv/machinery/modules/repair-list.vue
@@ -29,7 +29,7 @@
        field: 'requireDate',
        title: '报修日期',
        width: 180,
        formatter: 'formatDateTime',
        formatter: 'formatDate',
      },
      { field: 'nickname', title: '操作人', minWidth: 120 },
      {
src/views/mes/dv/machinery/type/data.ts
@@ -178,7 +178,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/maintenrecord/data.ts
@@ -76,9 +76,9 @@
      label: '保养时间',
      component: 'DatePicker',
      componentProps: {
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        placeholder: '请选择保养时间',
        showTime: true,
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
@@ -146,7 +146,7 @@
      field: 'maintenTime',
      title: '保养时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    { field: 'nickname', title: '保养人', minWidth: 120 },
    {
@@ -162,7 +162,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/metering/data.ts
@@ -361,7 +361,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/meteringcheck/data.ts
@@ -7,7 +7,6 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getSimpleUserList } from '#/api/system/user';
import { DvMeteringSelect } from '#/views/mes/dv/metering/components';
/** è¡¨å•类型 */
@@ -83,15 +82,13 @@
      },
    },
    {
      fieldName: 'checkUserId',
      fieldName: 'checkUserName',
      label: '检测人',
      component: 'ApiSelect',
      component: 'Input',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择检测人',
        valueField: 'id',
        placeholder: '请输入检测人姓名',
        maxlength: 100,
      },
    },
    {
@@ -193,7 +190,7 @@
      },
    },
    { field: 'certNo', title: '证书编号', minWidth: 130 },
    { field: 'checkUserNickname', title: '检测人', minWidth: 100 },
    { field: 'checkUserName', title: '检测人', minWidth: 100 },
    {
      field: 'nextCheckDate',
      title: '下次检测日期',
@@ -204,7 +201,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/repair/data.ts
@@ -300,13 +300,13 @@
      field: 'requireDate',
      title: '报修日期',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'finishDate',
      title: '完成日期',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'result',
@@ -330,7 +330,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/dv/repair/modules/form.vue
@@ -4,6 +4,7 @@
import type { MesDvRepairApi } from '#/api/mes/dv/repair';
import { computed, ref } from 'vue';
import { useAccess } from '@vben/access';
import { useVbenModal } from '@vben/common-ui';
import { MesDvRepairResultEnum, MesDvRepairStatusEnum } from '@vben/constants';
@@ -23,6 +24,8 @@
import { useFormSchema } from '../data';
import LineList from './line-list.vue';
import SpareRecords from '../../spare/modules/records.vue';
const { hasAccessByCodes } = useAccess();
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
@@ -210,6 +213,13 @@
      :disabled="isLineReadonly"
      :repair-id="formData.id"
    />
    <SpareRecords
      v-if="formData?.id && hasAccessByCodes(['mes:dv-spare:record'])"
      :key="formData.id"
      :repair-id="formData.id"
      :repair-code="formData.code"
      :can-issue="[0, 1].includes(formData.status ?? -1)"
    />
    <template #prepend-footer>
      <div class="flex flex-auto items-center gap-2">
        <Popconfirm
src/views/mes/dv/spare-record/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import Records from '../spare/modules/records.vue';
</script>
<template><Page><Records /></Page></template>
src/views/mes/dv/spare/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { SparePart } from '#/api/mes/dv/spare';
import { onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Alert, Button, Checkbox, Form, Input, Popconfirm, Select, Space, Table, Tag } from 'ant-design-vue';
import { deleteSpare, getSparePage } from '#/api/mes/dv/spare';
import SpareForm from './modules/form.vue';
import Action from './modules/action.vue';
import Records from './modules/records.vue';
const rows=ref<SparePart[]>([]),loading=ref(false),error=ref('');
const page=ref(1),size=ref(10),total=ref(0),selected=ref<SparePart>();
const filters=reactive<{code?:string;name?:string;status?:number;shortageOnly:boolean}>({shortageOnly:false});
const [FormModal,formApi]=useVbenModal({connectedComponent:SpareForm,destroyOnClose:true});
const [ActionModal,actionApi]=useVbenModal({connectedComponent:Action,destroyOnClose:true});
const [RecordsModal,recordsApi]=useVbenModal({showConfirmButton:false});
let seq=0;
const columns=[{title:'备件编码',dataIndex:'code',width:140},{title:'备件名称',dataIndex:'name',width:180},
 {title:'规格型号',dataIndex:'specification',width:160},{title:'单位',dataIndex:'unit',width:80},
 {title:'可用库存',dataIndex:'stockQuantity',key:'stock',width:130},{title:'安全库存',dataIndex:'safetyQuantity',width:110},
 {title:'存放位置',dataIndex:'location',width:150},{title:'状态',dataIndex:'status',key:'status',width:90},
 {title:'操作',key:'action',width:290,fixed:'right' as const}];
async function load(reset=false) {
 if(reset)page.value=1;const current=++seq;loading.value=true;error.value='';
 try{const result=await getSparePage({pageNo:page.value,pageSize:size.value,...filters});if(current===seq){rows.value=result.list;total.value=result.total;}}
 catch{if(current===seq){rows.value=[];total.value=0;error.value='备件加载失败,请点击查询重试';}}
 finally{if(current===seq)loading.value=false;}
}
function reset(){Object.assign(filters,{code:undefined,name:undefined,status:undefined,shortageOnly:false});void load(true);}
function changePage(p:{current?:number;pageSize?:number}){page.value=p.current||1;size.value=p.pageSize||10;void load();}
function showForm(part?:SparePart){formApi.setData({id:part?.id}).open();}
function action(type:'issue'|'receive',part:SparePart){actionApi.setData({type,part}).open();}
function history(part:SparePart){selected.value=part;recordsApi.open();}
async function remove(part:SparePart){await deleteSpare(part.id);await load(true);}
onMounted(()=>load());
</script>
<template>
 <Page>
  <FormModal @success="load(true)" /><ActionModal @success="load(true)" />
  <RecordsModal :title="selected ? selected.name+' Â· åº“存与领用记录' : '库存与领用记录'" class="w-full lg:w-4/5"><Records v-if="selected" :key="selected.id" :spare-part-id="selected.id" /></RecordsModal>
  <div class="mb-4 flex flex-wrap items-center justify-between gap-3"><h2 class="text-lg font-semibold">备件台账</h2><Button v-access:code="['mes:dv-spare:create']" type="primary" @click="showForm()">新增备件</Button></div>
  <Form layout="inline" class="mb-4 flex flex-wrap gap-y-3">
   <Form.Item label="备件编码"><Input v-model:value="filters.code" placeholder="完整备件编码" allow-clear @press-enter="load(true)" /></Form.Item>
   <Form.Item label="备件名称"><Input v-model:value="filters.name" placeholder="输入备件名称" allow-clear @press-enter="load(true)" /></Form.Item>
   <Form.Item label="状态"><Select v-model:value="filters.status" class="w-28" allow-clear :options="[{label:'启用',value:0},{label:'停用',value:1}]" /></Form.Item>
   <Form.Item><Checkbox v-model:checked="filters.shortageOnly">仅看低于安全库存</Checkbox></Form.Item>
   <Form.Item><Space><Button type="primary" :loading="loading" @click="load(true)">查询</Button><Button @click="reset">重置</Button></Space></Form.Item>
  </Form>
  <Alert v-if="error" class="mb-3" type="error" :message="error" />
  <Table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :scroll="{x:1330}" :pagination="{current:page,pageSize:size,total,showSizeChanger:true}" @change="changePage">
   <template #bodyCell="{column,record}">
    <template v-if="column.key==='stock'"><span>{{ record.stockQuantity }}</span><Tag v-if="Number(record.stockQuantity)<Number(record.safetyQuantity)" color="warning" class="ml-2">不足</Tag></template>
    <template v-else-if="column.key==='status'"><Tag :color="record.status===0?'success':'default'">{{ record.status===0?'启用':'停用' }}</Tag></template>
    <template v-else-if="column.key==='action'"><Space :size="0" wrap>
     <Button v-access:code="['mes:dv-spare:update']" type="link" @click="showForm(record as SparePart)">编辑</Button>
     <Button v-access:code="['mes:dv-spare:receive']" type="link" :disabled="record.status!==0" @click="action('receive',record as SparePart)">入库</Button>
     <Button v-access:code="['mes:dv-spare:issue']" type="link" :disabled="record.status!==0 || Number(record.stockQuantity)<=0" @click="action('issue',record as SparePart)">领用</Button>
     <Button v-access:code="['mes:dv-spare:record']" type="link" @click="history(record as SparePart)">记录</Button>
     <Popconfirm title="确认删除备件?仅无库存且无流水的备件可删除。" @confirm="remove(record as SparePart)"><Button v-access:code="['mes:dv-spare:delete']" type="link" danger>删除</Button></Popconfirm>
    </Space></template>
   </template>
  </Table>
 </Page>
</template>
src/views/mes/dv/spare/modules/action.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { SpareMovement, SparePart, SpareAction } from '#/api/mes/dv/spare';
import type { FormInstance } from 'ant-design-vue';
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Form, Input, InputNumber, Select } from 'ant-design-vue';
import { getSparePage, spareAction } from '#/api/mes/dv/spare';
import { getRepairPage } from '#/api/mes/dv/repair';
import { getSimpleUserList } from '#/api/system/user';
const emit = defineEmits(['success']);
const formRef = ref<FormInstance>();
const type = ref<'issue'|'receive'|'return'>('issue');
const saving = ref(false);
const loadError = ref('');
const original = ref<SpareMovement>();
const parts = ref<SparePart[]>([]);
const repairs = ref<{label:string;value:number}[]>([]);
const users = ref<{label:string;value:number}[]>([]);
const model = reactive<SpareAction>({ requestKey: '', quantity: 1, remark: '' });
const fixedPart = ref(false), fixedRepair = ref(false);
let partSeq = 0, repairSeq = 0, openSeq = 0;
const title = computed(() => ({issue:'维修领用备件',receive:'备件入库',return:'退回未使用备件'})[type.value]);
const chosenPart = computed(() => parts.value.find(p => p.id === model.sparePartId));
const maxQuantity = computed(() => type.value === 'return' ? Number(original.value?.returnableQuantity ?? 0) : type.value === 'issue' ? Number(chosenPart.value?.stockQuantity ?? 0) : undefined);
const required = [{required:true,message:'请填写此项'}];
async function searchParts(name='') {
  const seq = ++partSeq;
  try { const result = await getSparePage({pageNo:1,pageSize:50,status:0,name}); if(seq === partSeq) parts.value=result.list; }
  catch { if(seq===partSeq) loadError.value='备件加载失败,请重新打开弹窗后重试'; }
}
async function searchRepairs(code='') {
  const seq=++repairSeq;
  try { const result=await getRepairPage({pageNo:1,pageSize:50,code} as Parameters<typeof getRepairPage>[0]);
    if(seq===repairSeq) repairs.value=result.list.filter(r=>r.status===0||r.status===1).map(r=>({value:r.id!,label:`${r.code} Â· ${r.machineryName || r.name || ''}`}));
  } catch { if(seq===repairSeq) loadError.value='维修单加载失败,请重新打开弹窗后重试'; }
}
const [Modal, modalApi] = useVbenModal({
  onBeforeClose:()=>!saving.value,
  async onOpenChange(open) {
    const seq=++openSeq;
    if(!open) { partSeq++; repairSeq++; return; }
    const data=modalApi.getData<{type:'issue'|'receive'|'return';part?:SparePart;repairId?:number;repairCode?:string;original?:SpareMovement}>();
    type.value=data.type; original.value=data.original; loadError.value='';
    fixedPart.value=!!data.part||data.type==='return'; fixedRepair.value=!!data.repairId;
    Object.assign(model,{requestKey:crypto.randomUUID(),sparePartId:data.part?.id,repairId:data.repairId,originalId:data.original?.id,receiverUserId:undefined,quantity:1,remark:''});
    parts.value=data.part?[data.part]:[]; repairs.value=[]; users.value=[]; formRef.value?.clearValidate();
    if(data.repairId) repairs.value=[{value:data.repairId,label:data.repairCode || String(data.repairId)}];
    modalApi.lock();
    try {
      const jobs:Promise<unknown>[]=[];
      if(data.type!=='return'&&!data.part) jobs.push(searchParts());
      if(data.type==='issue'&&!data.repairId) jobs.push(searchRepairs());
      if(data.type==='issue') jobs.push(getSimpleUserList().then(list=>{if(seq===openSeq)users.value=list.filter(u=>u.id!==undefined).map(u=>({value:u.id!,label:u.nickname}));}));
      await Promise.all(jobs);
    } catch { if(seq===openSeq) loadError.value='基础数据加载失败,请重新打开弹窗后重试'; }
    finally { if(seq===openSeq) modalApi.unlock(); }
  },
  async onConfirm() {
    if(saving.value||loadError.value)return;
    await formRef.value?.validate();
    saving.value=true;modalApi.lock();
    try { await spareAction(type.value,{...model}); emit('success');saving.value=false;await modalApi.close(); }
    finally { saving.value=false;modalApi.unlock(); }
  },
});
const quantityRules = [{required:true,message:'请输入数量'}, {validator:async (_:unknown,value:number)=>{
  if(!Number.isFinite(value)||value<=0)throw new Error('数量必须大于0');
  if(maxQuantity.value!==undefined && value>maxQuantity.value) throw new Error(type.value==='return'?'超过可退回数量':'超过当前可用库存');
}}];
</script>
<template>
  <Modal :title="title" class="w-full sm:w-[600px]">
    <Alert v-if="loadError" :message="loadError" type="error" class="mb-4" />
    <Alert v-else class="mb-4" type="info" :message="type === 'issue' ? '领用立即扣减库存,并关联当前维修单。请核对备件、领用人和数量。' : type === 'return' ? '仅退回未使用的备件,累计退回不能超过原领用数量。' : '入库增加库存并保留记录,请填写补货来源或单据编号。'" />
    <Form ref="formRef" :model="model" layout="vertical">
      <Form.Item v-if="type === 'return'" label="原领用记录">
        {{ original?.repairCode }} Â· {{ original?.sparePartName }} Â· å¯é€€å›ž {{ original?.returnableQuantity }} {{ original?.unit }}
      </Form.Item>
      <Form.Item v-else label="备件" name="sparePartId" :rules="required">
        <Select v-model:value="model.sparePartId" show-search :disabled="fixedPart || saving" :filter-option="false" placeholder="输入备件名称查找" :options="parts.map(p=>({value:p.id,label:p.code+' Â· '+p.name+' Â· åº“å­˜ '+p.stockQuantity+' '+p.unit}))" @search="searchParts" />
      </Form.Item>
      <Form.Item v-if="type === 'issue'" label="关联维修单" name="repairId" :rules="required">
        <Select v-model:value="model.repairId" :disabled="fixedRepair || saving" show-search :filter-option="false" :options="repairs" placeholder="输入维修单编号查找(草稿或维修中)" @search="searchRepairs" />
      </Form.Item>
      <Form.Item v-if="type === 'issue'" label="领用人" name="receiverUserId" :rules="required">
        <Select v-model:value="model.receiverUserId" show-search option-filter-prop="label" :options="users" placeholder="请选择领用人" :disabled="saving" />
      </Form.Item>
      <Form.Item :label="'数量'+(chosenPart ? '('+chosenPart.unit+')' : '')" name="quantity" :rules="quantityRules">
        <InputNumber v-model:value="model.quantity" :min="0.001" :precision="3" class="w-full" :disabled="saving" />
      </Form.Item>
      <Form.Item label="操作说明" name="remark" :rules="required"><Input.TextArea v-model:value="model.remark" :maxlength="500" :rows="3" :disabled="saving" placeholder="填写领用用途、入库来源或退回原因" /></Form.Item>
    </Form>
  </Modal>
</template>
src/views/mes/dv/spare/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { SpareSave } from '#/api/mes/dv/spare';
import type { FormInstance } from 'ant-design-vue';
import { reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Form, Input, InputNumber, Select } from 'ant-design-vue';
import { createSpare, getSpare, updateSpare } from '#/api/mes/dv/spare';
const emit = defineEmits(['success']);
const formRef = ref<FormInstance>();
const saving = ref(false);
const model = reactive<SpareSave>({ code: '', name: '', unit: 'ä»¶', initialQuantity: 0, safetyQuantity: 0, status: 0 });
const required = [{ required: true, message: '请填写此项' }];
const [Modal, modalApi] = useVbenModal({
  onBeforeClose: () => !saving.value,
  async onOpenChange(open) {
    if (!open) return;
    Object.assign(model, { id: undefined, code: '', name: '', specification: '', unit: 'ä»¶', location: '', initialQuantity: 0, safetyQuantity: 0, status: 0, remark: '' });
    formRef.value?.clearValidate();
    const { id } = modalApi.getData<{ id?: number }>();
    if (id) { modalApi.lock(); try { Object.assign(model, await getSpare(id)); } finally { modalApi.unlock(); } }
  },
  async onConfirm() {
    if (saving.value) return;
    await formRef.value?.validate();
    saving.value = true; modalApi.lock();
    try { await (model.id ? updateSpare(model) : createSpare(model)); emit('success'); saving.value = false; await modalApi.close(); }
    finally { saving.value = false; modalApi.unlock(); }
  },
});
</script>
<template>
  <Modal :title="model.id ? '编辑备件' : '新增备件'" class="w-full sm:w-[640px]">
    <Alert class="mb-4" type="info" :message="model.id ? '库存通过入库、领用和退回变更。已有流水后不可修改计量单位。' : '初始库存登记后会保留流水,后续补货请使用入库。'" />
    <Form ref="formRef" :model="model" layout="vertical" class="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
      <Form.Item label="备件编码" name="code" :rules="required"><Input v-model:value="model.code" :maxlength="64" /></Form.Item>
      <Form.Item label="备件名称" name="name" :rules="required"><Input v-model:value="model.name" :maxlength="128" /></Form.Item>
      <Form.Item label="规格型号" name="specification"><Input v-model:value="model.specification" :maxlength="255" /></Form.Item>
      <Form.Item label="计量单位" name="unit" :rules="required"><Input v-model:value="model.unit" :maxlength="32" /></Form.Item>
      <Form.Item label="存放位置" name="location"><Input v-model:value="model.location" :maxlength="128" /></Form.Item>
      <Form.Item label="安全库存" name="safetyQuantity" :rules="required"><InputNumber v-model:value="model.safetyQuantity" :min="0" :precision="3" class="w-full" /></Form.Item>
      <Form.Item v-if="!model.id" label="初始库存" name="initialQuantity" :rules="required"><InputNumber v-model:value="model.initialQuantity" :min="0" :precision="3" class="w-full" /></Form.Item>
      <Form.Item label="状态" name="status"><Select v-model:value="model.status" :options="[{label:'启用',value:0},{label:'停用',value:1}]" /></Form.Item>
      <Form.Item label="备注" name="remark" class="sm:col-span-2"><Input.TextArea v-model:value="model.remark" :maxlength="500" :rows="3" /></Form.Item>
    </Form>
  </Modal>
</template>
src/views/mes/dv/spare/modules/records.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { SpareMovement } from '#/api/mes/dv/spare';
import { onMounted, reactive, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, Form, Input, Select, Space, Table } from 'ant-design-vue';
import { getSpareMovements, movementLabels } from '#/api/mes/dv/spare';
import Action from './action.vue';
const props=defineProps<{repairId?:number;sparePartId?:number;canIssue?:boolean;repairCode?:string}>();
const rows=ref<SpareMovement[]>([]),loading=ref(false),error=ref('');
const page=ref(1),size=ref(10),total=ref(0);
const filters=reactive<{repairCode?:string;type?:number}>({type:3});
let seq=0;
const [ActionModal,actionApi]=useVbenModal({connectedComponent:Action,destroyOnClose:true});
const columns=[
 {title:'操作时间',dataIndex:'createTime',key:'createTime',width:175},
 {title:'类型',dataIndex:'type',key:'type',width:95},
 {title:'备件编码',dataIndex:'sparePartCode',width:135},
 {title:'备件名称',dataIndex:'sparePartName',width:150},
 {title:'规格型号',dataIndex:'specification',width:130},
 {title:'数量 / å•位',key:'quantity',width:110},
 {title:'已退回',dataIndex:'returnedQuantity',width:90},
 {title:'可退回',dataIndex:'returnableQuantity',width:90},
 {title:'维修单号',dataIndex:'repairCode',width:145},
 {title:'设备',dataIndex:'machineryName',width:150},
 {title:'领用人',dataIndex:'receiverName',width:110},
 {title:'操作说明',dataIndex:'remark',width:220},
 {title:'操作',key:'action',width:90,fixed:'right' as const},
];
async function load(reset=false) {
 if(reset)page.value=1;
 const current=++seq;loading.value=true;error.value='';
 try {const result=await getSpareMovements({pageNo:page.value,pageSize:size.value,...filters,repairId:props.repairId,sparePartId:props.sparePartId});
 if(current===seq){rows.value=result.list;total.value=result.total;}}
 catch {if(current===seq){rows.value=[];total.value=0;error.value='记录加载失败,请点击查询重试';}}
 finally {if(current===seq)loading.value=false;}
}
function changePage(p:{current?:number;pageSize?:number}){page.value=p.current||1;size.value=p.pageSize||10;void load();}
function issue(){actionApi.setData({type:'issue',repairId:props.repairId,repairCode:props.repairCode}).open();}
function returnPart(record:SpareMovement){actionApi.setData({type:'return',original:record}).open();}
watch(()=>[props.repairId,props.sparePartId],()=>load(true));
onMounted(()=>load());
defineExpose({refresh:()=>load(true)});
</script>
<template>
 <div class="mt-4">
  <ActionModal @success="load(true)" />
  <div class="mb-3 flex flex-wrap items-center justify-between gap-3"><h3 class="text-base font-semibold">备件领用记录</h3>
   <Button v-if="canIssue" v-access:code="['mes:dv-spare:issue']" type="primary" @click="issue">领用备件</Button>
  </div>
  <Form layout="inline" class="mb-4 flex flex-wrap gap-y-3">
   <Form.Item v-if="!repairId" label="维修单号"><Input v-model:value="filters.repairCode" allow-clear placeholder="完整维修单号" @press-enter="load(true)" /></Form.Item>
   <Form.Item label="记录类型"><Select v-model:value="filters.type" allow-clear class="w-36" :options="Object.entries(movementLabels).map(([value,label])=>({label,value:Number(value)}))" /></Form.Item>
   <Form.Item><Space><Button :loading="loading" @click="load(true)">查询</Button><Button @click="filters.repairCode=undefined;filters.type=3;load(true)">重置</Button></Space></Form.Item>
  </Form>
  <Alert v-if="error" type="error" :message="error" class="mb-3" />
  <Table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :scroll="{x:1790}" :pagination="{current:page,pageSize:size,total,showSizeChanger:true}" @change="changePage">
   <template #bodyCell="{column,record}">
    <template v-if="column.key==='type'">{{ movementLabels[record.type] }}</template>
    <template v-else-if="column.key==='quantity'">{{ record.quantity }} {{ record.unit }}</template>
    <template v-else-if="column.key==='action'"><Button v-if="record.type===3 && Number(record.returnableQuantity)>0" v-access:code="['mes:dv-spare:return']" type="link" @click="returnPart(record as SpareMovement)">退回</Button><span v-else>—</span></template>
   </template>
  </Table>
 </div>
</template>
src/views/mes/dv/subject/data.ts
@@ -196,7 +196,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/home/modules/alert-panel.vue
@@ -20,14 +20,6 @@
/** å¾…办提醒列表:标签、描述、图标、目标路由名称、数量 */
const alertItems = computed(() => [
  {
    count: props.summary.andonActiveCount,
    desc: '未处置的安灯呼叫',
    icon: 'lucide:bell-ring',
    iconClass: 'bg-red-50 text-red-500',
    label: '安灯报警',
    routeName: 'MesProAndonRecord',
  },
  {
    count: props.summary.repairActiveCount,
    desc: '待处理的维修订单',
    icon: 'lucide:wrench',
src/views/mes/home/modules/kpi-cards.vue
@@ -18,31 +18,58 @@
  navigate: [name: string];
}>();
/** æ˜¯å¦æœ‰è´¨é‡æ•°æ®ï¼ˆåˆæ ¼å“ + ä¸è‰¯å“ > 0) */
const hasQualityData = computed(
  () =>
    props.summary.todayQualifiedQuantity +
      props.summary.todayUnqualifiedQuantity >
    0,
);
/**
 * è´¨é‡åˆæ ¼çŽ‡ï¼šæ¥æ–™ / è¿‡ç¨‹ / å‡ºè´§ä¸‰ä¸ªæ£€éªŒçŽ¯èŠ‚ï¼Œå„è‡ªä¸€å¼ å¡ç‰‡
 *
 * ç”±åŽç«¯æŒ‰ã€Œåˆæ ¼æ•°é‡ / æ£€éªŒæ•°é‡ã€è®¡ç®—(不过滤单据状态,无时间限制),
 * è¯¥çŽ¯èŠ‚æ²¡æœ‰æ£€éªŒæ•°æ®æ—¶åŽç«¯è¿”å›ž null。
 */
const qualityRates = computed(() => [
  {
    label: '来料合格率',
    rate: props.summary.iqcQualifiedRate,
    checkQuantity: props.summary.iqcCheckQuantity,
    routeName: 'MesQcIqc',
    icon: 'lucide:package-check',
    color: '#e6a23c',
    colorLight: '#ebb563',
  },
  {
    label: '过程合格率',
    rate: props.summary.ipqcQualifiedRate,
    checkQuantity: props.summary.ipqcCheckQuantity,
    routeName: 'MesQcIpqc',
    icon: 'lucide:workflow',
    color: '#13c2c2',
    colorLight: '#5cdbd3',
  },
  {
    label: '出货合格率',
    rate: props.summary.oqcQualifiedRate,
    checkQuantity: props.summary.oqcCheckQuantity,
    routeName: 'MesQcOqc',
    icon: 'lucide:truck',
    color: '#eb2f96',
    colorLight: '#f759ab',
  },
]);
/** è´¨é‡åˆæ ¼çއ = åˆæ ¼å“ / (合格品 + ä¸è‰¯å“) * 100,无数据时为 0 */
const qualityRate = computed(() => {
  const total =
    props.summary.todayQualifiedQuantity +
    props.summary.todayUnqualifiedQuantity;
  if (total === 0) {
    return 0;
  }
  return (props.summary.todayQualifiedQuantity / total) * 100;
});
/** åˆæ ¼çŽ‡å±•ç¤ºæ–‡æ¡ˆï¼ˆä¸€ä½å°æ•°ï¼Œä¸ŽåŽŸã€Œè´¨é‡åˆæ ¼çŽ‡ã€å¡ç‰‡ä¸€è‡´ï¼‰ï¼Œæ— æ•°æ®æ—¶ç”¨ã€Œâ€”ã€è€Œä¸æ˜¯ 0% */
function formatRate(rate: null | number | undefined) {
  return rate === null || rate === undefined ? '—' : rate.toFixed(1);
}
/** æ— æ£€éªŒæ•°æ®æ—¶ä¸æ¸²æŸ“百分号,避免出现「—%」 */
function hasRate(rate: null | number | undefined) {
  return rate !== null && rate !== undefined;
}
</script>
<template>
  <Row :gutter="16">
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesProWorkOrder')"
      >
        <div class="flex items-center gap-4">
@@ -71,9 +98,9 @@
        </div>
      </Card>
    </Col>
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesProFeedback')"
      >
        <div class="flex items-center gap-4">
@@ -100,44 +127,53 @@
        </div>
      </Card>
    </Col>
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col
      v-for="item in qualityRates"
      :key="item.label"
      :lg="6"
      :md="12"
      :sm="12"
      :xl="4"
      :xs="24"
      class="mb-4"
    >
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesProFeedback')"
        class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', item.routeName)"
      >
        <div class="flex items-center gap-4">
          <div
            class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white"
            style="background: linear-gradient(135deg, #e6a23c, #ebb563)"
            :style="{ background: `linear-gradient(135deg, ${item.color}, ${item.colorLight})` }"
          >
            <IconifyIcon class="size-7" icon="lucide:circle-check" />
            <IconifyIcon class="size-7" :icon="item.icon" />
          </div>
          <div>
            <div class="text-muted-foreground mb-1 text-sm">质量合格率</div>
            <div class="text-muted-foreground mb-1 text-sm">{{ item.label }}</div>
            <div class="flex items-baseline gap-1">
              <CountTo
                class="text-2xl font-bold leading-tight text-[#e6a23c]"
                :decimals="1"
                :end-val="qualityRate"
                :duration="1500"
              />
              <span class="text-muted-foreground text-xs">%</span>
              <span
                class="text-2xl font-bold leading-tight"
                :style="{ color: item.color }"
              >
                {{ formatRate(item.rate) }}
              </span>
              <span
                v-if="hasRate(item.rate)"
                class="text-muted-foreground text-xs"
              >
                %
              </span>
            </div>
            <div class="text-muted-foreground mt-1 text-xs">
              <template v-if="hasQualityData">
                <span>合格 {{ summary.todayQualifiedQuantity }}</span>
                <Divider type="vertical" />
                <span>不良 {{ summary.todayUnqualifiedQuantity }}</span>
              </template>
              <span v-else>暂无数据</span>
              <span>检验 {{ item.checkQuantity }}</span>
            </div>
          </div>
        </div>
      </Card>
    </Col>
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', 'MesDvMachinery')"
      >
        <div class="flex items-center gap-4">
src/views/mes/md/autocode/data.ts
@@ -214,7 +214,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/md/client/components/select.vue
@@ -113,7 +113,6 @@
    <Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
      <template #title>
        <div v-if="selectedItem" class="leading-6">
          <div>编码:{{ selectedItem.code || '-' }}</div>
          <div>名称:{{ selectedItem.name || '-' }}</div>
          <div>简称:{{ selectedItem.nickname || '-' }}</div>
          <div>电话:{{ selectedItem.telephone || '-' }}</div>
src/views/mes/md/client/data.ts
@@ -8,15 +8,6 @@
export function useClientSelectGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '客户编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入客户编码',
      },
    },
    {
      fieldName: 'name',
      label: '客户名称',
      component: 'Input',
@@ -52,11 +43,6 @@
): VxeTableGridOptions<MesMdClientApi.Client>['columns'] {
  return [
    { type: multiple ? 'checkbox' : 'radio', width: 50 },
    {
      field: 'code',
      title: '客户编码',
      minWidth: 160,
    },
    {
      field: 'name',
      title: '客户名称',
src/views/mes/md/item/components/select-dialog.vue
@@ -3,11 +3,11 @@
  import type { MesMdItemApi } from "#/api/mes/md/item";
  import type { MesMdItemTypeApi } from "#/api/mes/md/item/type";
  import { nextTick, ref } from "vue";
  import { computed, nextTick, ref } from "vue";
  import { CommonStatusEnum } from "@vben/constants";
  import { Card, message, Modal } from "ant-design-vue";
  import { Alert, Card, message, Modal, Tag } from "ant-design-vue";
  import { useVbenVxeGrid } from "#/adapter/vxe-table";
  import { getItemPage } from "#/api/mes/md/item";
@@ -27,6 +27,9 @@
  const selectedItemTypeId = ref<number>(); // å½“前筛选分类编号
  const preSelectedIds = ref<number[]>([]); // é¢„选物料编号列表
  const productIds = ref<number[]>([]); // é™å®šå¯é€‰çš„物料编号列表(为空则不限制)
  const limitedToProducts = computed(() => productIds.value.length > 0);
  const dialogTitle = computed(() => limitedToProducts.value ? "路线产出选择" : "物料产品选择");
  const tableTitle = computed(() => limitedToProducts.value ? "已配置的产出产品" : "物料产品列表");
  const typeTreeRef = ref<InstanceType<typeof MdItemTypeTree>>(); // ç‰©æ–™åˆ†ç±»æ ‘
  /** èŽ·å–å½“å‰è¡¨æ ¼æ•°æ® */
@@ -161,7 +164,7 @@
    },
    gridOptions: {
      columns: useItemSelectGridColumns(true),
      height: 560,
      height: '100%',
      keepSource: true,
      checkboxConfig: {
        highlight: true,
@@ -265,18 +268,29 @@
<template>
  <Modal v-model:open="open"
         title="物料产品选择"
         :title="dialogTitle"
         width="80%"
         :destroy-on-close="true"
         @ok="handleConfirm"
         @cancel="closeModal">
    <div class="flex h-full w-full">
      <Card class="mr-4 h-full w-1/5">
        <MdItemTypeTree ref="typeTreeRef"
                        @node-click="handleItemTypeNodeClick" />
      </Card>
      <div class="w-4/5">
        <Grid table-title="物料产品列表" />
    <div class="flex h-[calc(100vh-300px)] min-h-[380px] w-full flex-col">
      <Alert
        v-if="limitedToProducts"
        class="mb-3 shrink-0"
        message="仅可选择本产品在工艺路线中已配置的产出物料"
        description="如需增加可选项,请先在工艺路线对应工序中配置产出并保存。"
        show-icon
        type="info"
      >
        <template #action><Tag color="blue">{{ productIds.length }} ä¸ªäº§å‡º</Tag></template>
      </Alert>
      <div class="flex min-h-0 w-full flex-1 gap-4">
        <Card v-if="!limitedToProducts" class="h-full w-1/5 shrink-0" :body-style="{ height: '100%', overflow: 'hidden' }">
          <MdItemTypeTree ref="typeTreeRef" @node-click="handleItemTypeNodeClick" />
        </Card>
        <div class="h-full min-h-0 min-w-0 flex-1">
          <Grid class="h-full" :table-title="tableTitle" />
        </div>
      </div>
    </div>
  </Modal>
src/views/mes/md/item/components/select.vue
@@ -42,7 +42,9 @@
const displayLabel = computed(() => {
  if (selectedItems.value.length === 0) return '';
  return selectedItems.value.map(item => item.name ?? '').join('、');
  return selectedItems.value.map(item =>
    [item.name, item.specification, item.code ? `(${item.code})` : ''].filter(Boolean).join(' '),
  ).join('、');
});
const showClear = computed(
  () =>
src/views/mes/md/item/data.ts
@@ -216,8 +216,14 @@
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料名称',
        placeholder: '支持名称与规格组合查询',
      },
    },
    {
      fieldName: 'specification',
      label: '规格型号',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入规格型号' },
    },
    {
      fieldName: 'status',
@@ -321,7 +327,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -350,8 +356,14 @@
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料名称',
        placeholder: '支持名称与规格组合查询',
      },
    },
    {
      fieldName: 'specification',
      label: '规格型号',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入规格型号' },
    },
  ];
}
@@ -405,7 +417,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
  ];
}
src/views/mes/md/item/modules/import-form.vue
@@ -6,11 +6,12 @@
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Upload } from 'ant-design-vue';
import { Button, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importItem, importItemTemplate } from '#/api/mes/md/item';
import { $t } from '#/locales';
import { notifyImportResult } from '#/utils';
import { useImportFormSchema } from '../data';
@@ -41,23 +42,21 @@
    try {
      const result = await importItem(data.file, data.updateSupport);
      const importData = result as MesMdItemApi.ItemImportRespVO;
      let text = `上传成功数量:${importData.createCodes?.length || 0};`;
      for (const code of importData.createCodes || []) {
        text += `< ${code} >`;
      }
      text += `更新成功数量:${importData.updateCodes?.length || 0};`;
      for (const code of importData.updateCodes || []) {
        text += `< ${code} >`;
      }
      text += `更新失败数量:${Object.keys(importData.failureCodes || {}).length};`;
      for (const code in importData.failureCodes || {}) {
        text += `< ${code}: ${importData.failureCodes?.[code]} >`;
      }
      message.info(text);
      // å…³é—­å¹¶æç¤º
      const createCount = importData.createCodes?.length || 0;
      const updateCount = importData.updateCodes?.length || 0;
      // å…³é—­å¹¶æŒ‰å¯¼å…¥ç»“果提示:仅在确有数据写入时通知列表刷新
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
      if (createCount + updateCount > 0) {
        emit('success');
      }
      notifyImportResult({
        subject: '物料',
        createCount,
        updateCount,
        failures: importData.failureCodes,
        masterDataHint: '【物料分类】中的对应编码',
      });
    } finally {
      modalApi.unlock();
    }
src/views/mes/md/item/type/data.ts
@@ -213,7 +213,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/md/workstation/components/workshop-select.vue
@@ -82,10 +82,6 @@
      <div v-if="selectedItem" class="leading-6">
        <div>编码:{{ selectedItem.code || '-' }}</div>
        <div>名称:{{ selectedItem.name || '-' }}</div>
        <div>
          é¢ç§¯ï¼š
          {{ selectedItem.area !== null ? `${selectedItem.area} ãŽ¡` : '-' }}
        </div>
        <div>负责人:{{ selectedItem.chargeUserName || '-' }}</div>
      </div>
    </template>
src/views/mes/md/workstation/data.ts
@@ -277,7 +277,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/md/workstation/workshop/data.ts
@@ -76,16 +76,6 @@
      rules: 'required',
    },
    {
      fieldName: 'area',
      label: '面积',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
      },
    },
    {
      fieldName: 'chargeUserId',
      label: '负责人',
      component: 'ApiSelect',
@@ -168,11 +158,6 @@
      field: 'name',
      title: '车间名称',
      minWidth: 150,
    },
    {
      field: 'area',
      title: '面积',
      width: 120,
    },
    {
      field: 'chargeUserName',
src/views/mes/pd/archive/data.ts
@@ -64,14 +64,14 @@
      field: 'archiveTime',
      title: '归档时间',
      minWidth: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    { field: 'updaterNickname', title: '更新人', minWidth: 100 },
    {
      field: 'updateTime',
      title: '更新时间',
      minWidth: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
src/views/mes/pd/archive/modules/detail.vue
@@ -43,6 +43,15 @@
  );
}
/**
 * èµ„料文件地址:后端只回 attachmentList(附件中间表),业务表没有 fileUrl å­—段,
 * è¿™é‡Œå–第一个附件的访问地址,没有附件就返回空串(模板里显示「-」)。
 */
function documentFileUrl(record: MesPdArchiveApi.DocumentInfo) {
  const blob = record.attachmentList?.[0];
  return blob?.url || blob?.downloadURL || '';
}
/** å½’档状态中文名:字典 mes_pd_archive_status */
function archiveStatusText(value?: number) {
  if (value === undefined || value === null || value === '') return '-';
@@ -170,9 +179,9 @@
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key === 'action'">
                  <template v-if="record.fileUrl">
                    <a class="mr-2" @click="openPreview(record.fileUrl, record.fileName)">预览</a>
                    <a :href="record.fileUrl" target="_blank">下载</a>
                  <template v-if="documentFileUrl(record)">
                    <a class="mr-2" @click="openPreview(documentFileUrl(record), record.fileName)">预览</a>
                    <a :href="documentFileUrl(record)" target="_blank">下载</a>
                  </template>
                  <span v-else class="text-gray-400">-</span>
                </template>
src/views/mes/pd/project/data.ts
@@ -268,7 +268,7 @@
      field: "createTime",
      title: "创建时间",
      minWidth: 160,
      formatter: "formatDateTime",
      formatter: 'formatDate',
    },
    {
      field: "action",
@@ -454,7 +454,7 @@
      field: "uploadTime",
      title: "上传时间",
      minWidth: 160,
      formatter: "formatDateTime",
      formatter: 'formatDate',
    },
    {
      field: "action",
src/views/mes/pd/project/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '#/packages/constants/src';
import { formatDate, formatDateTime } from '#/packages/utils/src';
import { formatDate } from '#/packages/utils/src';
import { DictTag } from '#/components/dict-tag';
@@ -31,7 +31,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => (val ? formatDateTime(val) : '-'),
      render: (val) => (val ? formatDate(val) : '-'),
    },
    { field: 'remark', label: '备注' },
  ];
src/views/mes/pd/project/detail/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitPdProjectApproval } from '#/api/mes/pd/project';
import type { FormType } from '../data';
import type { MesPdProjectApi } from '#/api/mes/pd/project';
@@ -137,8 +139,15 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit() {
  const hideLoading = message.loading({ content: '获取审批流程...', duration: 0 });
  const hideLoading = message.loading({ content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...', duration: 0 });
  try {
    if (automaticApproval.value) {
      await submitPdProjectApproval(projectId.value, 'pd_project_approve');
      hideLoading();
      message.success('已提交生效');
      await loadProjectDetail();
      return;
    }
    const processList = await getPdProjectApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -146,6 +155,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: projectId.value, processList }).open();
  } catch {
    hideLoading();
src/views/mes/pd/project/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitPdProjectApproval } from '#/api/mes/pd/project';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesPdProjectApi } from '#/api/mes/pd/project';
@@ -83,10 +85,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: MesPdProjectApi.Project) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitPdProjectApproval(row.id!, 'pd_project_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getPdProjectApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -94,6 +103,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
@@ -259,7 +269,7 @@
            {
              label: '归档',
              type: 'link',
              auth: ['mes:pd-project:archive'],
              auth: ['mes:pd-project:update'],
              ifShow: row.status === MesPdProjectStatusEnum.PENDING_ARCHIVE,
              popConfirm: {
                title: '确认归档?归档后将生成台账记录,任务不可再修改。',
src/views/mes/pd/project/modules/document-audit-form.vue
@@ -23,7 +23,8 @@
    { fieldName: 'documentVersion', label: '资料版本', component: 'Input', componentProps: { disabled: true } },
    { fieldName: 'reviewerNickname', label: '审核人', component: 'Input', componentProps: { disabled: true } },
    {
      fieldName: 'auditStatus',
      // å­—段名不能用 auditStatus:自动审批模式下会被 adapter/form çš„ isAuditField æ•´æ¡è¿‡æ»¤æŽ‰ï¼Œå¯¼è‡´å®¡æ ¸äººæ— æ³•选择
      fieldName: 'auditResult',
      label: '审核结果',
      component: 'Select',
      componentProps: {
@@ -47,7 +48,7 @@
    modalApi.lock();
    try {
      const raw = await formApi.getValues();
      await updatePdDocumentAudit({ id: raw.id, auditStatus: raw.auditStatus, remark: raw.remark, auditTime: Date.now() });
      await updatePdDocumentAudit({ id: raw.id, auditStatus: raw.auditResult, remark: raw.remark, auditTime: Date.now() });
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
      await modalApi.close();
@@ -59,9 +60,8 @@
    if (!isOpen) return;
    const data = modalApi.getData<MesPdDocumentAuditApi.DocumentAudit>();
    modalApi.setState({ title: '资料审核' });
    const formData = { ...data };
    delete formData.auditStatus;
    await formApi.setValues(formData);
    // æ¯æ¬¡æ‰“开都清空审核结果,避免复用上一行已选的值
    await formApi.setValues({ ...data, auditResult: undefined });
  },
});
</script>
src/views/mes/pd/project/modules/document-audit-list.vue
@@ -49,6 +49,7 @@
      { field: 'auditNo', title: '审核单号', minWidth: 140 },
      { field: 'documentName', title: '资料名称', minWidth: 160 },
      { field: 'documentVersion', title: '资料版本', minWidth: 90 },
      { field: 'fileUrl', title: '文件', minWidth: 140, slots: { default: 'file' } },
      { field: 'reviewerNickname', title: '审核人', minWidth: 100 },
      { field: 'auditStatus', title: '审核状态', minWidth: 100, cellRender: { name: 'CellDict', props: { type: DICT_TYPE.MES_PD_DOCUMENT_AUDIT_STATUS } } },
      { field: 'auditTime', title: '审核时间', minWidth: 160, formatter: 'formatDateTime' },
@@ -78,6 +79,12 @@
<template>
  <AuditFormModal @success="handleRefresh" />
  <Grid table-title="资料审核">
    <template #file="{ row }">
      <a v-if="row.fileUrl" :href="row.fileUrl" target="_blank" class="text-primary">
        {{ row.fileName || '查看文件' }}
      </a>
      <span v-else class="text-gray-400">-</span>
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
src/views/mes/pd/project/modules/task/data.ts
@@ -57,7 +57,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'action',
src/views/mes/pd/project/modules/version-change/data.ts
@@ -54,7 +54,7 @@
      field: 'createTime',
      title: '申请时间',
      minWidth: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
  ];
src/views/mes/pro/andon/record/data.ts
@@ -5,7 +5,7 @@
import { markRaw } from 'vue';
import { DICT_TYPE, MesProWorkOrderStatusEnum } from '@vben/constants';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getSimpleUserList } from '#/api/system/user';
@@ -89,7 +89,7 @@
      field: 'createTime',
      title: '发起时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    { field: 'reason', title: '呼叫原因', minWidth: 160 },
    {
@@ -191,7 +191,6 @@
          component: markRaw(ProWorkOrderSelect),
          componentProps: {
            placeholder: '请选择订单(可选)',
            status: MesProWorkOrderStatusEnum.CONFIRMED,
          },
        }
      : {
src/views/mes/pro/card/data.ts
@@ -8,7 +8,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { Button } from 'ant-design-vue';
@@ -85,7 +84,6 @@
      componentProps: {
        disabled: headerReadonly,
        placeholder: '请选择生产订单',
        status: MesProWorkOrderStatusEnum.CONFIRMED,
      },
      rules: 'selectRequired',
    },
src/views/mes/pro/card/index.vue
@@ -3,7 +3,7 @@
import type { MesProCardApi } from '#/api/mes/pro/card';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesProCardStatusEnum } from '@vben/constants';
import { BarcodeBizTypeEnum, MesProCardStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
@@ -185,7 +185,7 @@
        <PrinterLabel
          :biz-code="row.code"
          :biz-id="row.id"
          biz-type="PROCARD"
          :biz-type="BarcodeBizTypeEnum.PROCARD"
        />
      </template>
    </Grid>
src/views/mes/pro/card/modules/form.vue
@@ -19,7 +19,10 @@
  updateCard,
} from '#/api/mes/pro/card';
import { $t } from '#/locales';
import { BarcodeDetail } from '#/views/wls/barcode/components';
import {
  BarcodeDetail,
  PrinterLabel,
} from '#/views/wls/barcode/components';
import { useFormSchema } from '../data';
import ProcessList from './process-list.vue';
@@ -76,6 +79,7 @@
    formData.value.id,
    BarcodeBizTypeEnum.PROCARD,
    formData.value.code,
    formData.value.itemName || formData.value.code,
  );
}
@@ -207,6 +211,14 @@
        >
          æŸ¥çœ‹æ¡ç 
        </Button>
        <PrinterLabel
          v-if="formType === 'detail' && formData?.id"
          :biz-id="formData.id"
          :biz-type="BarcodeBizTypeEnum.PROCARD"
          :biz-code="formData.code"
          :biz-name="formData.itemName || formData.code"
          title="流转卡"
        />
      </div>
    </template>
  </Modal>
src/views/mes/pro/feedback/data.ts
@@ -10,9 +10,9 @@
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProTaskStatusEnum,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate } from '@vben/utils';
import { Button } from 'ant-design-vue';
@@ -20,6 +20,7 @@
import { getRouteProcessByRouteAndProcess } from '#/api/mes/pro/route/process';
import { getSimpleUserList } from '#/api/system/user';
import { loadParamDetails } from './param-state';
import { getRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
@@ -147,7 +148,7 @@
      field: 'feedbackTime',
      title: '报工时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    { field: 'approveUserNickname', title: '审核人', width: 100 },
    {
@@ -252,7 +253,6 @@
      componentProps: {
        disabled: isHeaderReadonly,
        placeholder: '请选择订单',
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        // è®¢å•变更:清空任务及任务带出的产品信息、数量区域控制位
        onChange: async () => {
          await formApi?.setValues({
@@ -488,7 +488,11 @@
    { field: 'materialScrapQuantity', label: '料废数量' },
    { field: 'otherScrapQuantity', label: '其他废品' },
    { field: 'feedbackUserNickname', label: '报工人' },
    { field: 'feedbackTime', label: '报工时间' },
    {
      field: 'feedbackTime',
      label: '报工时间',
      render: (val) => formatDate(val) as string,
    },
    { field: 'approveUserNickname', label: '审核人' },
    { field: 'remark', label: '备注' },
  ];
src/views/mes/pro/feedback/modules/batch-input-dialog.vue
@@ -65,7 +65,7 @@
        fieldName: 'expireDate',
        label: '有效期',
        component: 'DatePicker',
        componentProps: { class: 'w-full', format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD HH:mm:ss', showTime: true, placeholder: '请选择有效期' },
        componentProps: { class: 'w-full', format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD HH:mm:ss', showTime: false, placeholder: '请选择有效期' },
        rules: 'required',
      });
    }
@@ -74,7 +74,7 @@
        fieldName: 'receiptDate',
        label: '入库日期',
        component: 'DatePicker',
        componentProps: { class: 'w-full', format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD HH:mm:ss', showTime: true, placeholder: '请选择入库日期' },
        componentProps: { class: 'w-full', format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD HH:mm:ss', showTime: false, placeholder: '请选择入库日期' },
        rules: 'required',
      });
    }
src/views/mes/pro/mps/data.ts
@@ -96,6 +96,7 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        allCustomers: true,
        placeholder: '请选择客户',
        disabled: !isCreate && !isEdit,
      },
@@ -161,6 +162,7 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        allCustomers: true,
        placeholder: '请选择客户',
      },
    },
@@ -250,11 +252,6 @@
      formatter: 'formatAmount3',
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 120,
@@ -287,7 +284,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/pro/mps/modules/form.vue
@@ -144,9 +144,6 @@
        <Descriptions.Item label="已生产数量">
          {{ formData.quantityProduced }}
        </Descriptions.Item>
        <Descriptions.Item label="客户编码">
          {{ formData.clientCode }}
        </Descriptions.Item>
        <Descriptions.Item label="客户名称">
          {{ formData.clientName }}
        </Descriptions.Item>
src/views/mes/pro/process/data.ts
@@ -161,7 +161,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/pro/route/components/index.ts
@@ -1 +1,2 @@
export { default as RouteColorPicker } from './color-picker.vue';
export { default as RouteOutputAttribution } from './output-attribution.vue';
src/views/mes/pro/route/components/output-attribution.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Select } from 'ant-design-vue';
defineOptions({ name: 'RouteOutputAttribution' });
/** å½’属选择中代表“多产品通用”的哨兵值(产品编号均为正数) */
const GENERAL_PRODUCT = 0;
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    modelValue?: MesProRouteProcessApi.OutputItem[];
    products?: MesProRouteProductApi.RouteProduct[];
  }>(),
  {
    disabled: false,
    modelValue: () => [],
    products: () => [],
  },
);
const emit = defineEmits<{
  'update:modelValue': [value: MesProRouteProcessApi.OutputItem[]];
}>();
const rows = computed(() => props.modelValue ?? []);
const productOptions = computed(() =>
  props.products
    .filter((product) => product.itemId != null)
    .map((product) => ({
      label: product.itemName ?? product.itemCode ?? String(product.itemId),
      value: product.itemId!,
    })),
);
const attributionOptions = computed(() => [
  { label: '通用(所有产品)', value: GENERAL_PRODUCT },
  ...productOptions.value,
]);
function commit(next: MesProRouteProcessApi.OutputItem[]) {
  emit('update:modelValue', next);
}
function updateRow(
  index: number,
  patch: Partial<MesProRouteProcessApi.OutputItem>,
) {
  commit(rows.value.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
/** é€‰æ‹©äº§å‡ºç‰©æ–™æ—¶ï¼Œé»˜è®¤å½’属到自身产品(半成品共用时再手动改成“通用”) */
function handleItemChange(index: number, itemId: number | undefined) {
  const current = rows.value[index];
  const patch: Partial<MesProRouteProcessApi.OutputItem> = { itemId };
  // åŽŸæ¥å½’å±žè‡ªèº«æ—¶ï¼Œåˆ‡æ¢äº§å‡ºç‰©æ–™ä¹Ÿè¦åŒæ­¥å½’å±žï¼Œé¿å…ç•™ä¸‹æ—§äº§å“ç¼–å·ã€‚
  if ((current?.productId == null || current.productId === current.itemId) && itemId != null) {
    patch.productId = itemId;
  }
  updateRow(index, patch);
}
function handleAttributionChange(index: number, value: number | undefined) {
  updateRow(index, {
    productId: value === GENERAL_PRODUCT || value == null ? undefined : value,
  });
}
function addRow() {
  commit([...rows.value, { itemId: undefined, productId: undefined }]);
}
function removeRow(index: number) {
  commit(rows.value.filter((_, i) => i !== index));
}
</script>
<template>
  <div class="w-full">
    <div class="flex flex-col gap-2">
      <div
        v-for="(row, index) in rows"
        :key="index"
        class="flex items-center gap-2"
      >
        <Select
          :disabled="disabled"
          :options="productOptions"
          :placeholder="'选择产出物料'"
          :value="row.itemId"
          class="min-w-0 flex-1"
          option-filter-prop="label"
          show-search
          @change="(value) => handleItemChange(index, value as number | undefined)"
        />
        <Select
          :disabled="disabled || row.itemId == null"
          :options="attributionOptions"
          :value="row.productId ?? GENERAL_PRODUCT"
          class="min-w-0 flex-1"
          @change="(value) => handleAttributionChange(index, value as number | undefined)"
        />
        <Button
          :aria-label="`删除第 ${index + 1} æ¡äº§å‡ºé…ç½®`"
          :disabled="disabled"
          danger
          size="small"
          type="text"
          @click="removeRow(index)"
        >
          <IconifyIcon class="size-4" icon="lucide:trash-2" />
        </Button>
      </div>
      <div v-if="rows.length === 0" class="text-sm text-gray-500">
        æš‚无产出;形成半成品或成品时添加
      </div>
    </div>
    <Button
      v-if="!disabled"
      class="mt-2"
      size="small"
      type="dashed"
      @click="addRow"
    >
      <IconifyIcon class="mr-1 size-4" icon="lucide:plus" />
      æ·»åŠ äº§å‡º
    </Button>
  </div>
</template>
src/views/mes/pro/route/data.ts
@@ -19,12 +19,15 @@
import { z } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplatePage } from '#/api/mes/pro/processdesign/template';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
import {
  MdItemSelect,
  MdProductBomSelect,
} from '#/views/mes/md/item/components';
import { RouteColorPicker } from './components';
import { RouteColorPicker, RouteOutputAttribution } from './components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
@@ -181,6 +184,7 @@
/** å·¥è‰ºè·¯çº¿å·¥åºæ˜Žç»†è¡¨å• */
export function useRouteProcessFormSchema(
  processOptions: Array<{ label: string; value: number }>,
  products: MesProRouteProductApi.RouteProduct[] = [],
): VbenFormSchema[] {
  return [
    {
@@ -230,24 +234,39 @@
      },
    },
    {
      fieldName: 'yieldRate',
      label: '工序收率',
      component: 'InputNumber',
      help: '产出量 Ã· æŠ•入量;未填写按 100% å‚与排产计算。',
      componentProps: {
        class: '!w-full',
        min: 0.0001,
        max: 1,
        step: 0.01,
        precision: 4,
        placeholder: '如 0.98(98%)',
      },
      rules: z.number().min(0.0001).max(1).nullish(),
    },
    {
      fieldName: 'outputs',
      label: '本工序产出',
      component: markRaw(RouteOutputAttribution),
      componentProps: {
        products,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'linkType',
      label: '与下道工序关系',
      component: 'Select',
      help: () =>
        h(
          'div',
          {
            style:
              'white-space: pre-line; max-width: 320px; line-height: 1.6;',
          },
          '0=开始-开始(SS):本工序开始后,下道工序才能开始(可重叠并行开工)\n1=结束-结束(FF):下道工序必须等到本工序结束才能结束(并行但同步完工)\n2=开始-结束(SF):本工序开始后,下道工序才能结束\n3=结束-开始(FS):本工序结束后,下道工序才能开始(默认值)',
        ),
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_LINK_TYPE, 'number'),
        placeholder: '请选择',
      },
      rules: z.number().optional(),
      rules: z.number().default(3),
    },
    {
      fieldName: 'colorCode',
@@ -271,6 +290,16 @@
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
    },
    {
      fieldName: 'backflushFlag',
      label: '报工自动扣料',
      component: 'Switch',
      componentProps: {
        checkedChildren: '自动',
        unCheckedChildren: '手动',
      },
      rules: z.boolean().default(false),
    },
@@ -316,6 +345,25 @@
    { field: 'sort', title: '序号', width: 70, align: 'center', fixed: 'left' },
    { field: 'processCode', title: '工序编码', width: 140, fixed: 'left' },
    { field: 'processName', title: '工序名称', width: 140, fixed: 'left' },
    {
      field: 'outputItemNames',
      title: '本工序产出',
      minWidth: 240,
      formatter: ({ cellValue, row }) => {
        const outputs = row.outputs ?? [];
        if (!outputs.length) {
          return cellValue ?? '';
        }
        return outputs
          .map((output) => {
            const name = output.itemName ?? output.itemCode ?? output.itemId;
            return output.productId == null
              ? `${name}(通用)`
              : `${name}(${output.productName ?? output.productId})`;
          })
          .join('、');
      },
    },
    { field: 'nextProcessName', title: '下一道工序', width: 140 },
    {
      field: 'linkType',
@@ -344,6 +392,15 @@
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    {
      field: 'backflushFlag',
      title: '自动扣料',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    { field: 'prepareTime', title: '准备时间(分)', width: 110 },
    { field: 'waitTime', title: '等待时间(分)', width: 110 },
    {
@@ -365,11 +422,14 @@
/** å·¥è‰ºè·¯çº¿äº§å“åˆ—表字段 */
export function useRouteProductGridColumns(): VxeTableGridOptions<MesProRouteProductApi.RouteProduct>['columns'] {
  return [
    { field: 'itemCode', title: '产品物料编码', width: 150 },
    { field: 'itemName', title: '产品物料名称', width: 150 },
    { field: 'itemCode', title: '产出编码', width: 150 },
    { field: 'itemName', title: '产出名称', width: 150 },
    { field: 'specification', title: '规格型号', width: 150 },
    { field: 'unitName', title: '单位', width: 80 },
    { field: 'quantity', title: '生产数量', width: 100 },
    { field: 'warehouseName', title: '报工入库仓', width: 140 },
    { field: 'locationName', title: '入库库区', width: 120 },
    { field: 'areaName', title: '入库库位', width: 120 },
    {
      field: 'productionTime',
      title: '生产用时',
@@ -389,8 +449,8 @@
/** å·¥è‰ºè·¯çº¿äº§å“ BOM åˆ—表字段 */
export function useRouteProductBomGridColumns(): VxeTableGridOptions<MesProRouteProductBomApi.RouteProductBom>['columns'] {
  return [
    { field: 'itemCode', title: 'BOM ç‰©æ–™ç¼–码', width: 150 },
    { field: 'itemName', title: 'BOM ç‰©æ–™åç§°', width: 150 },
    { field: 'itemCode', title: '投料编码', width: 150 },
    { field: 'itemName', title: '投料名称', width: 150 },
    { field: 'specification', title: '规格型号', width: 150 },
    { field: 'unitName', title: '单位', width: 80 },
    { field: 'quantity', title: '用料比例', width: 100 },
@@ -407,6 +467,7 @@
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å• */
export function useRouteProductFormSchema(
  onItemChange?: (item: any) => void,
  getFormApi?: () => VbenFormApi,
): VbenFormSchema[] {
  return [
    {
@@ -422,11 +483,12 @@
    // äº§å“ç‰©æ–™ï¼šä½¿ç”¨ä¸šåŠ¡è‡ªå®šä¹‰é€‰æ‹©å™¨ï¼Œchange å›žå¡«ç¼–码/名称/规格/单位
    {
      fieldName: 'itemId',
      label: '产品',
      label: '产出物料',
      component: markRaw(MdItemSelect),
      componentProps: {
        onChange: onItemChange,
      },
      help: '可选择主数据类型为成品或半成品的物料,也兼容 MES æˆå“/半成品分类;已有路线产品可继续修改仓位。',
      formItemClass: 'col-span-2',
      rules: 'selectRequired',
    },
@@ -442,6 +504,63 @@
      rules: z.number().default(1),
    },
    {
      fieldName: 'warehouseId',
      label: '报工仓库',
      component: 'ApiSelect',
      componentProps: {
        api: getWarehouseSimpleList,
        labelField: 'name',
        valueField: 'id',
        placeholder: '请选择报工产出仓库',
        onChange: async () => {
          await getFormApi?.().setFieldValue('locationId', undefined);
          await getFormApi?.().setFieldValue('areaId', undefined);
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '报工库区',
      component: 'Select',
      dependencies: {
        triggerFields: ['warehouseId'],
        disabled: (values) => !values.warehouseId,
        async componentProps(values) {
          const options = values.warehouseId
            ? await getWarehouseLocationSimpleList(values.warehouseId)
            : [];
          return {
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工产出库区',
            onChange: async () => getFormApi?.().setFieldValue('areaId', undefined),
          };
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'areaId',
      label: '报工库位',
      component: 'Select',
      dependencies: {
        triggerFields: ['locationId'],
        disabled: (values) => !values.locationId,
        async componentProps(values) {
          const options = values.locationId
            ? await getWarehouseAreaSimpleList(values.locationId)
            : [];
          return {
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工产出库位',
          };
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'productionTime',
      label: '生产用时',
      component: 'InputNumber',
src/views/mes/pro/route/index.vue
@@ -8,25 +8,33 @@
import { getDictLabel } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tooltip } from 'ant-design-vue';
import { Alert, Button, message, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteRoute,
  exportRoute,
  getRoutePage,
  importRoute,
  importRouteTemplate,
  updateRouteStatus,
} from '#/api/mes/pro/route';
import ImportModalComponent from '#/components/import-modal';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import Form from './modules/route-configurator.vue';
const { hasAccessByCodes } = useAccess();
const statusEditable = hasAccessByCodes(['mes:pro-route:update']); // æ˜¯å¦å¯åˆ‡æ¢çŠ¶æ€
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportModalComponent,
  destroyOnClose: true,
});
@@ -38,6 +46,18 @@
/** åˆ›å»ºå·¥è‰ºè·¯çº¿ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
function handleImport() {
  importModalApi
    .setData({
      title: $t('ui.actionTitle.import', ['工艺路线']),
      fileName: '工艺路线导入模板.xls',
      importFn: importRoute,
      templateFn: importRouteTemplate,
    })
    .open();
}
/** ç¼–辑工艺路线(仅停用状态可编辑) */
@@ -57,7 +77,9 @@
): Promise<boolean | undefined> {
  try {
    await confirm(
      `确认要将"${row.name}"工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
      newStatus === CommonStatusEnum.ENABLE
        ? `确认启用“${row.name}”吗?系统将检查产出入库位置、工序产出和工序投料是否完整。`
        : `确认要将“${row.name}”工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
    );
  } catch {
    return false;
@@ -118,6 +140,14 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Alert
      class="mb-3"
      message="路线配置顺序"
      description="基本信息 â†’ äº§å‡ºäº§å“åŠå…¥åº“仓 â†’ å·¥åºåŠå„工序产出 â†’ å·¥åºæŠ•æ–™ â†’ æ ¡éªŒå¯ç”¨"
      show-icon
      type="info"
    />
    <Grid table-title="工艺路线列表">
      <template #toolbar-tools>
        <TableAction
@@ -130,6 +160,12 @@
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.import', ['工艺路线']),
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/pro/route/modules/material-input-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { computed, ref, watch } from 'vue';
import { Alert, Button, Empty, Tag } from 'ant-design-vue';
import { getRouteProductBomList } from '#/api/mes/pro/route/productbom';
import ProductBomList from './product-bom-list.vue';
const props = defineProps<{
  formType: FormType;
  processes: MesProRouteProcessApi.RouteProcess[];
  products: MesProRouteProductApi.RouteProduct[];
  routeId: number;
}>();
const emit = defineEmits<{
  change: [list: MesProRouteProductBomApi.RouteProductBom[]];
}>();
const activeProductId = ref<number>();
const allBomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]);
const activeProduct = computed(() =>
  props.products.find((item) => item.itemId === activeProductId.value),
);
function materialCount(productId?: number) {
  if (!productId) return 0;
  return allBomList.value.filter((item) => item.productId === productId).length;
}
async function loadAll() {
  allBomList.value = (await getRouteProductBomList({ routeId: props.routeId })) || [];
  emit('change', allBomList.value);
}
watch(
  () => props.products.map((item) => item.itemId).filter(Boolean),
  (productIds) => {
    if (!productIds.length) {
      activeProductId.value = undefined;
      return;
    }
    if (!activeProductId.value || !productIds.includes(activeProductId.value)) {
      activeProductId.value = productIds[0];
    }
  },
  { immediate: true },
);
watch(
  () => props.routeId,
  (value) => value && loadAll(),
  { immediate: true },
);
</script>
<template>
  <Alert
    class="mb-4"
    message="按产出产品配置每道工序需要投入的物料"
    description="先选择产出产品,再切换到具体工序添加投料物料和用料比例。开启“报工自动扣料”的工序,会在报工时按这里的配置自动扣减库存。"
    show-icon
    type="info"
  />
  <Empty
    v-if="!products.length || !processes.length"
    :description="!products.length ? '请先完成产出产品配置' : '请先完成工序配置'"
  />
  <div v-else class="material-layout">
    <aside class="product-selector" aria-label="选择要配置投料的产出产品">
      <div class="selector-heading">
        <strong>产出产品</strong>
        <span>{{ products.length }} ä¸ª</span>
      </div>
      <Button
        v-for="product in products"
        :key="product.itemId"
        :class="['product-button', { active: product.itemId === activeProductId }]"
        type="text"
        @click="activeProductId = product.itemId"
      >
        <span class="product-copy">
          <strong>{{ product.itemName }}</strong>
          <small>{{ product.itemCode }}</small>
        </span>
        <Tag :color="materialCount(product.itemId) ? 'success' : 'warning'">
          {{ materialCount(product.itemId) ? `${materialCount(product.itemId)} æ¡` : '未配置' }}
        </Tag>
      </Button>
    </aside>
    <section v-if="activeProduct" class="material-detail">
      <header class="material-detail-header">
        <div>
          <h3>{{ activeProduct.itemName }} Â· å·¥åºæŠ•æ–™</h3>
          <p>依次切换工序,维护该产品在该工序实际消耗的物料。</p>
        </div>
        <Tag color="blue">{{ processes.length }} é“工序</Tag>
      </header>
      <ProductBomList
        :form-type="formType"
        :product-id="activeProduct.itemId!"
        :product-name="activeProduct.itemName"
        :route-id="routeId"
        @change="loadAll"
      />
    </section>
  </div>
</template>
<style scoped>
.material-layout { display: grid; grid-template-columns: minmax(210px, 260px) minmax(0, 1fr); gap: 18px; }
.product-selector { overflow: hidden; align-self: start; border: 1px solid rgb(226 232 240); border-radius: 12px; background: rgb(248 250 252); }
.selector-heading { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgb(226 232 240); color: rgb(15 23 42); }
.selector-heading span { color: rgb(100 116 139); font-size: 12px; }
.product-button { display: flex; width: 100%; height: auto; min-height: 62px; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 0; text-align: left; }
.product-button + .product-button { border-top: 1px solid rgb(226 232 240); }
.product-button.active { background: rgb(219 234 254); color: rgb(29 78 216); }
.product-copy { display: grid; min-width: 0; gap: 2px; }
.product-copy strong, .product-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.product-copy small { color: rgb(100 116 139); }
.material-detail { min-width: 0; padding: 18px; border: 1px solid rgb(226 232 240); border-radius: 12px; }
.material-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.material-detail-header h3 { margin: 0; color: rgb(15 23 42); font-size: 16px; font-weight: 650; }
.material-detail-header p { margin: 4px 0 0; color: rgb(100 116 139); font-size: 13px; }
:global(.dark) .product-selector, :global(.dark) .material-detail { border-color: rgb(51 65 85); background: rgb(15 23 42); }
:global(.dark) .selector-heading, :global(.dark) .product-button + .product-button { border-color: rgb(51 65 85); color: rgb(241 245 249); }
:global(.dark) .product-button.active { background: rgb(30 64 175 / 35%); color: rgb(147 197 253); }
:global(.dark) .material-detail-header h3 { color: rgb(241 245 249); }
@media (max-width: 900px) {
  .material-layout { grid-template-columns: 1fr; }
  .product-selector { display: flex; overflow-x: auto; }
  .selector-heading { display: none; }
  .product-button { min-width: 210px; border-top: 0 !important; border-right: 1px solid rgb(226 232 240); }
}
</style>
src/views/mes/pro/route/modules/process-form.vue
@@ -6,7 +6,7 @@
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
@@ -15,6 +15,7 @@
  getRouteProcess,
  updateRouteProcess,
} from '#/api/mes/pro/route/process';
import { getRouteProductListByRoute } from '#/api/mes/pro/route/product';
import { $t } from '#/locales';
import { useRouteProcessFormSchema } from '../data';
@@ -31,7 +32,7 @@
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 150,
    labelWidth: 130,
  },
  layout: 'horizontal',
  schema: [],
@@ -40,13 +41,14 @@
});
/** åŠ è½½å·¥åºé€‰é¡¹åŽå†ç”Ÿæˆ schema(避免下拉空选项) */
async function loadSchema(): Promise<VbenFormSchema[]> {
async function loadSchema(routeId: number): Promise<VbenFormSchema[]> {
  const list = await getProcessSimpleList();
  const options = (list || []).map((item) => ({
    label: item.name ?? '',
    value: item.id!,
  }));
  return useRouteProcessFormSchema(options);
  const products = routeId ? await getRouteProductListByRoute(routeId) : [];
  return useRouteProcessFormSchema(options, products);
}
const [Modal, modalApi] = useVbenModal({
@@ -76,15 +78,6 @@
      formData.value = undefined;
      return;
    }
    modalApi.lock();
    try {
      // å·¥åºä¸‹æ‹‰ä¾èµ–远程数据,schema åœ¨å¼¹çª—打开时再生成
      const schema = await loadSchema();
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      maxSort?: number;
@@ -93,6 +86,15 @@
    if (!data) {
      return;
    }
    modalApi.lock();
    try {
      // å·¥åºä¸‹æ‹‰ä¾èµ–远程数据,schema åœ¨å¼¹çª—打开时再生成
      const schema = await loadSchema(data.routeId);
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    // åŠ è½½æ•°æ®
    if (!data.id) {
      // æ–°å¢žæ—¶ï¼Œé»˜è®¤åºå· = maxSort + 1,并给一个默认甘特图颜色
      await formApi.setValues({
@@ -116,6 +118,13 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      class="mx-4 mb-4"
      message="按实际生产节点配置产出"
      description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,末道工序必须选择全部成品。归属表示该产出属于哪个产品:多产品共用的半成品选“通用”,产品专属(尤其是成品)归属到自身产品。如开启报工自动扣料,报工时会按工序投料配置扣减库存。"
      show-icon
      type="info"
    />
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/pro/route/modules/process-list.vue
@@ -4,11 +4,11 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message, Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -25,8 +25,15 @@
  routeId: number;
}>();
const isEditable = ref(props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const emit = defineEmits<{
  change: [list: MesProRouteProcessApi.RouteProcess[]];
}>();
const isEditable = computed(() => props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const list = ref<MesProRouteProcessApi.RouteProcess[]>([]); // å·¥è‰ºè·¯çº¿å·¥åºåˆ—表
const sortedList = computed(() =>
  [...list.value].sort((a, b) => (a.sort || 0) - (b.sort || 0)),
);
const [ProcessFormModal, processFormModalApi] = useVbenModal({
  connectedComponent: ProcessForm,
@@ -53,6 +60,7 @@
  try {
    list.value = await getRouteProcessListByRoute(props.routeId);
    gridApi.setGridOptions({ data: list.value });
    emit('change', list.value);
  } finally {
    gridApi.setLoading(false);
  }
@@ -89,6 +97,16 @@
<template>
  <ProcessFormModal @success="getList" />
  <div v-if="sortedList.length" class="mb-4 flex flex-wrap items-center gap-2 rounded-lg bg-slate-50 p-3 dark:bg-slate-900">
    <template v-for="(item, index) in sortedList" :key="item.id">
      <div class="flex items-center gap-2 rounded-md border border-slate-200 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-800">
        <span class="text-xs font-semibold text-blue-600">{{ item.sort }}</span>
        <span class="font-medium">{{ item.processName }}</span>
        <Tag v-if="index === sortedList.length - 1" color="blue">末道</Tag>
      </div>
      <span v-if="index < sortedList.length - 1" class="text-slate-400">→</span>
    </template>
  </div>
  <div v-if="isEditable" class="mb-3 flex items-center justify-start">
    <TableAction
      :actions="[
@@ -100,7 +118,7 @@
      ]"
    />
  </div>
  <Grid class="w-full" table-title="组成工序">
  <Grid class="w-full" table-title="工序顺序及各工序产出">
    <template #colorCode="{ row }">
      <div v-if="row.colorCode" class="flex items-center justify-center gap-1">
        <div
src/views/mes/pro/route/modules/product-bom-list.vue
@@ -1,9 +1,10 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -21,10 +22,17 @@
import BomForm from './bom-form.vue';
const props = defineProps<{
  formType: FormType;
  productId: number;
  productName?: string;
  routeId: number;
}>();
const emit = defineEmits<{
  change: [];
}>();
const isEditable = computed(() => props.formType !== 'detail');
const processList = ref<MesProRouteProcessApi.RouteProcess[]>([]); // å·¥åºåˆ—表(用于 Tab)
const activeProcessId = ref<number>(); // å½“前选中的工序 Tab
@@ -112,6 +120,12 @@
  await deleteRouteProductBom(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['BOM ç‰©æ–™']));
  await getList();
  emit('change');
}
async function handleSaved() {
  await getList();
  emit('change');
}
watch(
@@ -126,7 +140,7 @@
</script>
<template>
  <BomFormModal @success="getList" />
  <BomFormModal @success="handleSaved" />
  <Tabs v-model:active-key="activeProcessId" @change="getList">
    <TabPane
      v-for="item in processList"
@@ -134,11 +148,11 @@
      :tab="item.processName"
    />
  </Tabs>
  <div class="mb-3 flex items-center justify-start">
  <div v-if="isEditable" class="mb-3 flex items-center justify-start">
    <TableAction
      :actions="[
        {
          label: $t('ui.actionTitle.create', ['BOM ç‰©æ–™']),
          label: $t('ui.actionTitle.create', ['投料物料']),
          type: 'primary',
          disabled: !activeProcessId,
          onClick: handleCreate,
@@ -146,21 +160,23 @@
      ]"
    />
  </div>
  <Grid class="w-full" table-title="产品 BOM">
  <Grid class="w-full" table-title="当前工序投料">
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.edit'),
            type: 'link',
            ifShow: () => isEditable,
            onClick: handleEdit.bind(null, row),
          },
          {
            label: $t('common.delete'),
            type: 'link',
            danger: true,
            ifShow: () => isEditable,
            popConfirm: {
              title: $t('ui.actionMessage.deleteConfirm', ['BOM ç‰©æ–™']),
              title: $t('ui.actionMessage.deleteConfirm', ['投料物料']),
              confirm: handleDelete.bind(null, row),
            },
          },
src/views/mes/pro/route/modules/product-form.vue
@@ -5,7 +5,7 @@
import { useVbenModal } from '@vben/common-ui';
import { Divider, message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -16,7 +16,6 @@
import { $t } from '#/locales';
import { useRouteProductFormSchema } from '../data';
import ProductBomList from './product-bom-list.vue';
const emit = defineEmits(['success']);
const formData = ref<MesProRouteProductApi.RouteProduct>();
@@ -46,7 +45,7 @@
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: useRouteProductFormSchema(handleItemChange),
  schema: useRouteProductFormSchema(handleItemChange, () => formApi),
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
@@ -64,14 +63,10 @@
    try {
      if (formData.value?.id) {
        await updateRouteProduct(data);
        // ç”¨æœ€æ–°è¡¨å•值同步 formData,确保产品 BOM å­è¡¨ç»‘定的 itemId ä¸Žè¡¨å•一致
        formData.value = { ...formData.value, ...data };
      } else {
        // æ–°å¢žæˆåŠŸåŽåˆ‡æ¢åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤äº§å“ BOM
        const id = await createRouteProduct(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
        await createRouteProduct(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
@@ -109,17 +104,13 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      class="mx-4 mb-4"
      message="这里维护产出及其报工入库位置"
      description="投料物料将在路线配置的“工序投料”步骤中按产品、按工序统一维护。"
      show-icon
      type="info"
    />
    <Form class="mx-4" />
    <!-- ç¼–辑模式下展示产品 BOM å­è¡¨ï¼Œæ–°å¢žæ¨¡å¼ä¸‹éšè— -->
    <template v-if="formData?.id && formData?.itemId">
      <Divider class="!my-3" orientation="left">产品 BOM é…ç½®</Divider>
      <div class="mx-4">
        <ProductBomList
          :route-id="formData.routeId!"
          :product-id="formData.itemId"
          :product-name="formData.itemName"
        />
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/route/modules/product-list.vue
@@ -4,7 +4,7 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
@@ -27,7 +27,11 @@
  routeId: number;
}>();
const isEditable = ref(props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const emit = defineEmits<{
  change: [list: MesProRouteProductApi.RouteProduct[]];
}>();
const isEditable = computed(() => props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const list = ref<MesProRouteProductApi.RouteProduct[]>([]); // å·¥è‰ºè·¯çº¿äº§å“åˆ—表
const [ProductFormModal, productFormModalApi] = useVbenModal({
@@ -55,6 +59,7 @@
  try {
    list.value = await getRouteProductListByRoute(props.routeId);
    gridApi.setGridOptions({ data: list.value });
    emit('change', list.value);
  } finally {
    gridApi.setLoading(false);
  }
@@ -94,14 +99,14 @@
    <TableAction
      :actions="[
        {
          label: $t('ui.actionTitle.create', ['关联产品']),
          label: $t('ui.actionTitle.create', ['产出产品']),
          type: 'primary',
          onClick: handleCreate,
        },
      ]"
    />
  </div>
  <Grid class="w-full" table-title="关联产品">
  <Grid class="w-full" table-title="产出产品及报工入库位置">
    <template #productionTime="{ row }">
      <span v-if="row.productionTime">
        {{ row.productionTime }}
src/views/mes/pro/route/modules/route-configurator.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,425 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, Empty, message, Steps, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createRoute, getRoute, updateRoute } from '#/api/mes/pro/route';
import { getRouteProcessListByRoute } from '#/api/mes/pro/route/process';
import { getRouteProductListByRoute } from '#/api/mes/pro/route/product';
import { getRouteProductBomList } from '#/api/mes/pro/route/productbom';
import { useFormSchema } from '../data';
import MaterialInputList from './material-input-list.vue';
import ProcessList from './process-list.vue';
import ProductList from './product-list.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const formData = ref<MesProRouteApi.Route>();
const activeStep = ref(0);
const productList = ref<MesProRouteProductApi.RouteProduct[]>([]);
const processList = ref<MesProRouteProcessApi.RouteProcess[]>([]);
const bomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]);
const isDetail = computed(() => formType.value === 'detail');
const isNew = computed(() => !formData.value?.id);
const routeId = computed(() => formData.value?.id);
const sortedProcessList = computed(() =>
  [...processList.value].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
);
const title = computed(() =>
  isDetail.value ? '查看工艺路线' : isNew.value ? '新建工艺路线' : '编辑工艺路线',
);
const stepItems = [
  { title: '基本信息', description: '路线名称与说明' },
  { title: '产出与入库', description: '产品及报工仓位' },
  { title: '工序与产出', description: '顺序和半成品/成品' },
  { title: '工序投料', description: '按产品与工序配置' },
  { title: '启用前检查', description: '集中检查缺失项' },
];
const productsWithWarehouse = computed(() =>
  productList.value.filter((item) => item.warehouseId && item.locationId && item.areaId).length,
);
const processesWithOutput = computed(() =>
  processList.value.filter((item) => item.outputItemIds?.length).length,
);
const lastProcess = computed(() => sortedProcessList.value.at(-1));
const configuredProductIds = computed(
  () => new Set(bomList.value.map((item) => item.productId).filter(Boolean)),
);
const productsWithBom = computed(() => configuredProductIds.value.size);
const reviewIssues = computed(() => {
  const issues: Array<{ step: number; text: string }> = [];
  if (!productList.value.length) issues.push({ step: 1, text: '还没有配置产出产品' });
  const missingWarehouse = productList.value.filter(
    (item) => !item.warehouseId || !item.locationId || !item.areaId,
  );
  if (missingWarehouse.length) {
    issues.push({
      step: 1,
      text: `${missingWarehouse.length} ä¸ªäº§å‡ºäº§å“æœªé…ç½®å®Œæ•´ä»“库、库区和库位`,
    });
  }
  if (!processList.value.length) issues.push({ step: 2, text: '还没有配置生产工序' });
  if (processList.value.length && !processList.value.some((item) => item.keyFlag)) {
    issues.push({ step: 2, text: '未设置关键工序' });
  }
  if (lastProcess.value && !lastProcess.value.outputItemIds?.length) {
    issues.push({
      step: 2,
      text: `末道工序“${lastProcess.value.processName || lastProcess.value.processCode || lastProcess.value.sort}”必须配置成品产出`,
    });
  }
  const lastOutputs = lastProcess.value?.outputs ?? [];
  if (
    lastOutputs.length &&
    lastOutputs.some(
      (output) => output.productId == null || output.productId !== output.itemId,
    )
  ) {
    issues.push({
      step: 2,
      text: '末道工序的成品产出必须归属到自身产品,不能使用“通用”',
    });
  }
  const productIds = new Set(productList.value.map((item) => item.itemId));
  const invalidOutputCount = processList.value.filter((process) =>
    process.outputItemIds?.some((itemId) => !productIds.has(itemId)),
  ).length;
  if (invalidOutputCount) {
    issues.push({
      step: 2,
      text: `${invalidOutputCount} é“工序的产出未包含在路线产出产品中`,
    });
  }
  const noBomProducts = productList.value.filter(
    (item) => item.itemId && !configuredProductIds.value.has(item.itemId),
  );
  if (noBomProducts.length) {
    issues.push({ step: 3, text: `${noBomProducts.length} ä¸ªäº§å“è¿˜æ²¡æœ‰é…ç½®ä»»ä½•工序投料` });
  }
  return issues;
});
const primaryText = computed(() => {
  if (activeStep.value === stepItems.length - 1) return isDetail.value ? '关闭' : '完成配置';
  if (activeStep.value === 0 && isNew.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',
});
async function loadConfiguration() {
  if (!routeId.value) return;
  const [products, processes, boms] = await Promise.all([
    getRouteProductListByRoute(routeId.value),
    getRouteProcessListByRoute(routeId.value),
    getRouteProductBomList({ routeId: routeId.value }),
  ]);
  productList.value = products || [];
  processList.value = processes || [];
  bomList.value = boms || [];
}
async function saveBaseInformation() {
  const { valid } = await formApi.validate();
  if (!valid) return false;
  modalApi.lock();
  try {
    const data = (await formApi.getValues()) as MesProRouteApi.Route;
    if (!routeId.value) {
      const id = await createRoute(data);
      formData.value = { ...data, id };
      await formApi.setFieldValue('id', id);
      formType.value = 'update';
      message.success('基本信息已保存,请继续配置产出产品和入库位置');
    } else {
      await updateRoute({ ...data, id: routeId.value });
      formData.value = { ...formData.value, ...data, id: routeId.value };
    }
    emit('success');
    return true;
  } finally {
    modalApi.unlock();
  }
}
function validateProducts() {
  if (!productList.value.length) {
    message.warning('请至少添加一个产出产品,再配置工序');
    return false;
  }
  if (productsWithWarehouse.value !== productList.value.length) {
    message.warning('请为每个产出产品配置完整的仓库、库区和库位');
    return false;
  }
  return true;
}
function validateProcesses() {
  if (!processList.value.length) {
    message.warning('请至少添加一道生产工序');
    return false;
  }
  return true;
}
function validateBom() {
  if (productsWithBom.value !== productList.value.length) {
    message.warning('每个产出产品至少要配置一条工序投料');
    return false;
  }
  return true;
}
async function goToStep(target: number) {
  if (target < 0 || target >= stepItems.length) return;
  if (target <= activeStep.value || isDetail.value) {
    activeStep.value = target;
    return;
  }
  if (!(await saveBaseInformation())) return;
  await loadConfiguration();
  if (target >= 2 && !validateProducts()) return void (activeStep.value = 1);
  if (target >= 3 && !validateProcesses()) return void (activeStep.value = 2);
  if (target >= 4 && !validateBom()) return void (activeStep.value = 3);
  activeStep.value = target;
}
async function handlePrimaryAction() {
  if (activeStep.value === stepItems.length - 1) {
    if (!isDetail.value && reviewIssues.value.length) {
      message.warning('仍有缺失配置,请根据检查结果补充后再完成');
      return;
    }
    await modalApi.close();
    return;
  }
  await goToStep(activeStep.value + 1);
}
const [Modal, modalApi] = useVbenModal({
  showConfirmButton: false,
  cancelText: '关闭',
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      productList.value = [];
      processList.value = [];
      bomList.value = [];
      activeStep.value = 0;
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(isDetail.value);
    if (!data.id) return;
    modalApi.lock();
    try {
      formData.value = await getRoute(data.id);
      await formApi.setValues(formData.value);
      await loadConfiguration();
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="title" class="w-[94vw] max-w-[1560px]">
    <div class="route-config-shell">
      <header class="route-config-header">
        <div class="min-w-0">
          <div class="flex flex-wrap items-center gap-2">
            <h2 class="m-0 text-lg font-semibold text-gray-900 dark:text-gray-100">
              {{ formData?.name || '新工艺路线' }}
            </h2>
            <Tag :color="isDetail ? 'default' : 'processing'">
              {{ isDetail ? '只读查看' : '配置中' }}
            </Tag>
          </div>
          <p class="mb-0 mt-1 text-sm text-gray-600 dark:text-gray-300">
            å·¥åºæŠ•料依赖产出产品和工序,因此在工序编排完成后集中配置。
          </p>
        </div>
        <div class="route-config-summary" aria-label="当前配置概览">
          <span>产出 {{ productList.length }}</span>
          <span>工序 {{ processList.length }}</span>
          <span>投料 {{ bomList.length }}</span>
        </div>
      </header>
      <Steps
        :current="activeStep"
        :items="stepItems"
        class="route-config-steps"
        responsive
        size="small"
        type="navigation"
        @change="goToStep"
      />
      <main class="route-config-content">
        <section v-show="activeStep === 0" class="route-step-panel">
          <div class="route-step-copy">
            <IconifyIcon icon="lucide:route" />
            <div>
              <h3>先定义这条路线是什么</h3>
              <p>编码用于系统识别,名称和说明帮助现场人员快速选择。</p>
            </div>
          </div>
          <Form class="route-base-form" />
        </section>
        <section v-if="routeId" v-show="activeStep === 1" class="route-step-panel">
          <Alert
            class="mb-4"
            message="先维护所有可能的工序产出"
            description="实际形成的中间半成品和末道工序的成品都要加入,并分别指定报工后进入的仓库、库区和库位。"
            show-icon
            type="info"
          />
          <ProductList
            :form-type="formType"
            :route-id="routeId"
            @change="productList = $event"
          />
        </section>
        <section v-if="routeId" v-show="activeStep === 2" class="route-step-panel">
          <Alert
            class="mb-4"
            message="按实际生产顺序编排工序"
            description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,最后一道工序必须选择路线绑定的全部成品。多产品共用的半成品归属选“通用”,成品必须归属到自身产品。"
            show-icon
            type="info"
          />
          <ProcessList
            :form-type="formType"
            :route-id="routeId"
            @change="processList = $event"
          />
        </section>
        <section v-if="routeId" v-show="activeStep === 3" class="route-step-panel">
          <MaterialInputList
            :form-type="formType"
            :processes="sortedProcessList"
            :products="productList"
            :route-id="routeId"
            @change="bomList = $event"
          />
        </section>
        <section v-show="activeStep === 4" class="route-step-panel">
          <div class="review-heading">
            <div>
              <h3>启用前检查</h3>
              <p>确认产出、仓位、工序和投料形成闭环后,再回到列表启用路线。</p>
            </div>
            <Tag :color="reviewIssues.length ? 'warning' : 'success'">
              {{ reviewIssues.length ? `${reviewIssues.length} é¡¹å¾…处理` : '配置完整' }}
            </Tag>
          </div>
          <div class="review-metrics">
            <div><strong>{{ productsWithWarehouse }}/{{ productList.length }}</strong><span>产品仓位完整</span></div>
            <div><strong>{{ processesWithOutput }}</strong><span>配置产出的工序</span></div>
            <div><strong>{{ productsWithBom }}/{{ productList.length }}</strong><span>产品已配置投料</span></div>
          </div>
          <div v-if="reviewIssues.length" class="issue-list" role="status">
            <button
              v-for="issue in reviewIssues"
              :key="issue.text"
              class="issue-item"
              type="button"
              @click="activeStep = issue.step"
            >
              <IconifyIcon icon="lucide:circle-alert" />
              <span>{{ issue.text }}</span>
              <IconifyIcon class="ml-auto" icon="lucide:chevron-right" />
            </button>
          </div>
          <Empty v-else description="所有必需配置均已完成">
            <template #image>
              <div class="review-success-icon"><IconifyIcon icon="lucide:badge-check" /></div>
            </template>
          </Empty>
        </section>
      </main>
    </div>
    <template #prepend-footer>
      <div class="flex flex-auto items-center justify-between gap-3">
        <Button v-if="activeStep > 0" @click="activeStep--">
          <IconifyIcon class="mr-1" icon="lucide:arrow-left" />上一步
        </Button>
        <span v-else class="text-sm text-gray-500">新建路线默认停用,配置完整后再启用</span>
        <Button type="primary" @click="handlePrimaryAction">
          {{ primaryText }}
          <IconifyIcon
            v-if="activeStep < stepItems.length - 1"
            class="ml-1"
            icon="lucide:arrow-right"
          />
        </Button>
      </div>
    </template>
  </Modal>
</template>
<style scoped>
.route-config-shell { min-height: min(720px, 72vh); background: rgb(248 250 252); color: rgb(15 23 42); }
.route-config-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px 24px 14px; background: white; border-bottom: 1px solid rgb(226 232 240); }
.route-config-summary { display: flex; flex-shrink: 0; gap: 8px; }
.route-config-summary span { padding: 5px 10px; border-radius: 8px; background: rgb(239 246 255); color: rgb(29 78 216); font-size: 12px; font-weight: 600; }
.route-config-steps { padding: 12px 20px 0; background: white; }
.route-config-content { padding: 20px 24px 28px; }
.route-step-panel { min-height: 460px; padding: 22px; border-radius: 14px; background: white; box-shadow: 0 10px 28px rgb(15 23 42 / 6%); }
.route-step-copy, .review-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 22px; }
.route-step-copy { justify-content: flex-start; }
.route-step-copy > svg { width: 38px; height: 38px; padding: 9px; border-radius: 10px; background: rgb(219 234 254); color: rgb(29 78 216); }
.route-step-copy h3, .review-heading h3 { margin: 0; color: rgb(15 23 42); font-size: 17px; font-weight: 650; }
.route-step-copy p, .review-heading p { margin: 4px 0 0; color: rgb(71 85 105); font-size: 14px; }
.route-base-form { max-width: 1040px; margin: 34px auto 0; }
.review-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 24px 0; }
.review-metrics > div { display: flex; align-items: baseline; gap: 10px; padding: 18px; border-radius: 12px; background: rgb(248 250 252); }
.review-metrics strong { color: rgb(29 78 216); font-size: 24px; }
.review-metrics span { color: rgb(71 85 105); font-size: 13px; }
.issue-list { display: grid; gap: 10px; }
.issue-item { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid rgb(253 230 138); border-radius: 10px; background: rgb(255 251 235); color: rgb(146 64 14); cursor: pointer; text-align: left; transition: background-color 180ms ease, border-color 180ms ease; }
.issue-item:hover, .issue-item:focus-visible { border-color: rgb(245 158 11); background: rgb(254 243 199); outline: none; }
.review-success-icon { display: grid; width: 72px; height: 72px; place-items: center; margin: 0 auto; border-radius: 50%; background: rgb(220 252 231); color: rgb(22 163 74); font-size: 38px; }
:global(.dark) .route-config-shell { background: rgb(15 23 42); color: rgb(226 232 240); }
:global(.dark) .route-config-header, :global(.dark) .route-config-steps, :global(.dark) .route-step-panel { background: rgb(30 41 59); border-color: rgb(51 65 85); }
:global(.dark) .route-step-copy h3, :global(.dark) .review-heading h3 { color: rgb(241 245 249); }
:global(.dark) .route-step-copy p, :global(.dark) .review-heading p, :global(.dark) .review-metrics span { color: rgb(203 213 225); }
:global(.dark) .review-metrics > div { background: rgb(15 23 42); }
@media (max-width: 900px) {
  .route-config-header { align-items: flex-start; flex-direction: column; }
  .review-metrics { grid-template-columns: 1fr; }
  .route-step-panel { padding: 16px; }
}
</style>
src/views/mes/pro/task/data.ts
@@ -86,6 +86,7 @@
/** å¾…排产订单列表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>['columns'] {
  return [
    { type: 'checkbox', width: 48, fixed: 'left' },
    {
      field: 'code',
      title: '订单编码',
@@ -143,15 +144,6 @@
      },
    },
    {
      field: 'productionStatus',
      title: '生产状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ORDER_PRODUCTION_STATUS },
      },
    },
    {
      field: 'quantity',
      title: '订单数量',
      width: 100,
@@ -173,11 +165,6 @@
      slots: { default: 'progressPercent' },
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      width: 120,
@@ -191,7 +178,7 @@
    {
      title: '操作',
      width: 160,
      width: 240,
      fixed: 'right',
      slots: { default: 'actions' },
    },
src/views/mes/pro/task/index.vue
@@ -7,12 +7,11 @@
import { Page, useVbenModal } from '@vben/common-ui';
import {
  MesProWorkOrderProductionStatusEnum,
  MesProWorkOrderStatusEnum,
  MesProWorkOrderTypeEnum,
} from '@vben/constants';
import { Button, Card, Progress, Tabs } from 'ant-design-vue';
import { Button, Card, Progress, Tabs, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getGanttTaskList } from '#/api/mes/pro/task';
@@ -22,6 +21,7 @@
import { GanttChart } from './components';
import { useGridColumns, useGridFormSchema } from './data';
import ScheduleForm from './modules/schedule-form.vue';
import AutoSchedule from './modules/auto-schedule.vue';
const router = useRouter();
const ganttTasks = ref<any[]>([]); // ç”˜ç‰¹å›¾é¢„览数据
@@ -35,9 +35,31 @@
  destroyOnClose: true,
});
const [AutoScheduleModal, autoScheduleModalApi] = useVbenModal({
  connectedComponent: AutoSchedule,
  destroyOnClose: true,
});
function canAutoSchedule(row: MesProWorkOrderApi.WorkOrder) {
  return row.status === MesProWorkOrderStatusEnum.CONFIRMED
    && row.type === MesProWorkOrderTypeEnum.SELF
    && !isCompletedWorkOrder(row)
    && (row.quantity ?? 0) > 0
    && (row.quantityScheduled ?? 0) < (row.quantity ?? 0);
}
function handleAutoSchedule(row?: MesProWorkOrderApi.WorkOrder) {
  const rows: MesProWorkOrderApi.WorkOrder[] = row ? [row] : gridApi.grid.getCheckboxRecords();
  if (!rows.length) return message.warning('请先勾选待排产工单');
  if (rows.length > 30) return message.warning('一次最多选择30个工单,请分批排产');
  if (rows.some((order) => !canAutoSchedule(order))) return message.warning('请选择已确认且仍需排产的自行生产工单');
  autoScheduleModalApi.setData({ workOrderIds: rows.map((order) => order.id!) }).open();
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.grid.clearCheckboxRow();
  gridApi.query();
  loadGanttPreview();
}
@@ -72,12 +94,9 @@
  router.push({ name: 'MesProTaskGanttEdit' });
}
/** åˆ¤æ–­è®¢å•是否为“已完成类型”:订单状态=已完成 æˆ– ç”Ÿäº§çŠ¶æ€=生产完成 */
/** åˆ¤æ–­è®¢å•是否为“已完成类型”:订单状态=已完成 */
function isCompletedWorkOrder(row: MesProWorkOrderApi.WorkOrder): boolean {
  return (
    row.status === MesProWorkOrderStatusEnum.FINISHED ||
    row.productionStatus === MesProWorkOrderProductionStatusEnum.FINISHED
  );
  return row.status === MesProWorkOrderStatusEnum.FINISHED;
}
/**
@@ -103,6 +122,11 @@
  gridOptions: {
    columns: useGridColumns(),
    keepSource: true,
    checkboxConfig: {
      checkStrictly: true,
      showHeader: true,
      checkMethod: ({ row }: { row: MesProWorkOrderApi.WorkOrder }) => canAutoSchedule(row),
    },
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
@@ -154,6 +178,7 @@
<template>
  <Page auto-content-height>
    <ScheduleModal @success="handleRefresh" />
    <AutoScheduleModal @success="handleRefresh" />
    <!-- æŽ’产甘特图预览 -->
    <Card title="排产甘特图" class="mb-4">
      <template #extra>
@@ -177,8 +202,14 @@
        <TableAction
          :actions="[
            {
              label: '甘特图编辑',
              label: '一键排产',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-task:create'],
              onClick: () => handleAutoSchedule(),
            },
            {
              label: '甘特图编辑',
              icon: ACTION_ICON.EDIT,
              onClick: handleGanttEdit,
            },
@@ -200,6 +231,13 @@
        <TableAction
          :actions="[
            {
              label: '一键排产',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: canAutoSchedule(row),
              onClick: () => handleAutoSchedule(row),
            },
            {
              label: '排产维护',
              type: 'link',
              auth: ['mes:pro-task:create'],
src/views/mes/pro/task/modules/auto-schedule.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<script setup lang="ts">
import type { BatchSchedulePreview, BatchScheduleRequest } from '#/api/mes/pro/task';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, Checkbox, DatePicker, Descriptions, Table, Tag, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { executeBatchSchedule, previewBatchSchedule } from '#/api/mes/pro/task';
const emit = defineEmits(['success']);
const ids = ref<number[]>([]);
function defaultStartTime() {
  let value = dayjs().add(5, 'minute').startOf('minute');
  if (value.hour() >= 18) value = value.add(1, 'day').hour(9).minute(0).second(0);
  else if (value.hour() < 9) value = value.hour(9).minute(0).second(0);
  while (value.day() === 0 || value.day() === 6) value = value.add(1, 'day').hour(9).minute(0).second(0);
  return value;
}
const startTime = ref(defaultStartTime());
const preview = ref<BatchSchedulePreview>();
const loading = ref(false);
const executing = ref(false);
const acceptLate = ref(false);
const errorText = ref('');
let sequence = 0;
let open = false;
const canConfirm = computed(() => !!preview.value?.canExecute && !loading.value && !executing.value
  && (!preview.value.warnings.length || acceptLate.value));
const [Modal, modalApi] = useVbenModal({
  confirmText: '确认排产',
  confirmDisabled: true,
  onBeforeClose: () => !executing.value,
  onConfirm: execute,
  async onOpenChange(isOpen) {
    open = isOpen;
    sequence++;
    if (!isOpen) {
      preview.value = undefined;
      ids.value = [];
      loading.value = false;
      errorText.value = '';
      return;
    }
    ids.value = modalApi.getData<{ workOrderIds: number[] }>().workOrderIds;
    startTime.value = defaultStartTime();
    await calculate();
  },
});
watch(canConfirm, (value) => modalApi.setState({ confirmDisabled: !value }));
watch(startTime, () => {
  sequence++;
  preview.value = undefined;
  acceptLate.value = false;
  loading.value = false;
  errorText.value = '';
}, { flush: 'sync' });
function request(): BatchScheduleRequest {
  return { workOrderIds: [...ids.value], startTime: startTime.value.format('YYYY-MM-DD HH:mm:ss') };
}
async function calculate() {
  if (executing.value || !startTime.value || !ids.value.length) return;
  const current = ++sequence;
  preview.value = undefined;
  acceptLate.value = false;
  errorText.value = '';
  loading.value = true;
  try {
    const result = await previewBatchSchedule(request());
    if (open && current === sequence) preview.value = result;
  } catch (error) {
    if (open && current === sequence) errorText.value = error instanceof Error ? error.message : '预览失败,请稍后重试';
  } finally {
    if (current === sequence) loading.value = false;
  }
}
async function execute() {
  if (!canConfirm.value || !preview.value) return;
  executing.value = true;
  errorText.value = '';
  modalApi.lock();
  try {
    const result = await executeBatchSchedule({ ...request(), previewHash: preview.value.previewHash,
      previewTime: preview.value.previewTime, allowLate: acceptLate.value });
    message.success(`排产成功:${ids.value.length} ä¸ªå·¥å•,生成 ${result.taskIds.length} ä¸ªä»»åŠ¡`);
    executing.value = false;
    await modalApi.close();
    emit('success');
  } catch (error) {
    errorText.value = error instanceof Error ? error.message : '执行结果未确认,请刷新列表并重新预览';
    // è¯·æ±‚失败或结果未确认后必须重算;旧预览不能直接再次提交。
    preview.value = undefined;
    acceptLate.value = false;
    emit('success'); // åˆ·æ–°å·¥å•和甘特图,核对超时请求是否已经提交。
  } finally {
    executing.value = false;
    modalApi.unlock();
  }
}
const orderColumns = [
  { title: '工单编码', dataIndex: 'workOrderCode', width: 170 },
  { title: '生产数量', dataIndex: 'quantity', width: 100 },
  { title: '新增任务', dataIndex: 'taskCount', width: 90 },
  { title: '需求日期', dataIndex: 'requestDate', width: 120 },
  { title: '预计完成', dataIndex: 'endTime', width: 175 },
  { title: '校验结果', dataIndex: 'error', width: 300 },
];
const taskColumns = [
  { title: '工单', dataIndex: 'workOrderCode', width: 160 },
  { title: '工序', dataIndex: 'processName', width: 130 },
  { title: '工作站', dataIndex: 'workstationName', width: 150 },
  { title: '设备', dataIndex: 'machineryNames', width: 180 },
  { title: '本次数量', dataIndex: 'quantity', width: 100 },
  { title: '小时产能', dataIndex: 'hourlyCapacity', width: 100 },
  { title: '工时(h)', dataIndex: 'duration', width: 85 },
  { title: '开工时间', dataIndex: 'startTime', width: 180 },
  { title: '完成时间', dataIndex: 'endTime', width: 180 },
];
</script>
<template>
  <Modal title="一键排产" class="w-[min(96vw,1200px)]">
    <div class="space-y-5 px-1">
      <Alert type="info" show-icon message="先校验与预览,再确认排产"
        description="按交期优先及工序顺序安排工作站与设备;每天09:00–18:00,排除周末和已配置节假日,非工作时间自动顺延。已部分排产的工单只补齐剩余数量,最多30个工单、200个新任务。" />
      <div class="flex flex-wrap items-end gap-3">
        <label class="flex flex-col gap-2">
          <span class="font-medium">最早开工时间</span>
          <DatePicker v-model:value="startTime" show-time :allow-clear="false" :disabled="executing"
            format="YYYY-MM-DD HH:mm:ss" :disabled-date="(date) => date.endOf('day').isBefore(dayjs())" />
        </label>
        <Button :loading="loading" :disabled="executing" @click="calculate">
          {{ preview ? '重新计算预览' : '生成排产预览' }}
        </Button>
        <span class="text-sm text-muted-foreground">已选择 {{ ids.length }} ä¸ªå·¥å•</span>
      </div>
      <Alert v-if="errorText" type="error" show-icon :message="errorText" />
      <template v-if="preview">
        <Descriptions :column="{ xs: 1, sm: 3 }" size="small" bordered>
          <Descriptions.Item label="工单数">{{ preview.orders.length }}</Descriptions.Item>
          <Descriptions.Item label="新增任务数">{{ preview.tasks.length }}</Descriptions.Item>
          <Descriptions.Item label="预览有效期">15分钟,提交时重新校验</Descriptions.Item>
        </Descriptions>
        <Alert v-if="!preview.canExecute" type="error" show-icon message="部分工单未通过校验,本批次不能提交"
          description="请根据下方校验结果修正工单、设备或已有任务的配置,再重新计算预览。" />
        <div>
          <h3 class="mb-2 font-medium">工单校验</h3>
          <Table :columns="orderColumns" :data-source="preview.orders" row-key="workOrderId"
            :pagination="false" size="small" :scroll="{ x: 1055 }">
            <template #bodyCell="{ column, record, text }">
              <template v-if="column.dataIndex === 'error'">
                <span v-if="record.error" class="text-destructive">{{ record.error }}</span>
                <Tag v-else :color="record.late ? 'orange' : 'green'">{{ record.late ? '有延期风险' : '通过' }}</Tag>
              </template>
              <template v-else-if="column.dataIndex === 'requestDate'">{{ text ? String(text).slice(0, 10) : '未指定' }}</template>
            </template>
          </Table>
        </div>
        <div v-if="preview.tasks.length">
          <h3 class="mb-2 font-medium">任务安排</h3>
          <Table :columns="taskColumns" :data-source="preview.tasks"
            :row-key="(row) => `${row.workOrderId}-${row.processId}`" :pagination="{ pageSize: 10, showSizeChanger: false }"
            size="small" :scroll="{ x: 1365 }" />
        </div>
        <Alert v-if="preview.warnings.length" type="warning" show-icon message="请核对交期风险">
          <template #description>
            <p v-for="warning in preview.warnings" :key="warning">{{ warning }}</p>
            <Checkbox v-model:checked="acceptLate" :disabled="executing" class="mt-2">
              æˆ‘已核对并接受以上交期延期风险
            </Checkbox>
          </template>
        </Alert>
      </template>
    </div>
  </Modal>
</template>
src/views/mes/pro/workorder-center/index.vue
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorkOrderApi, TaskInfo } from '#/api/mes/pro/workorder';
import type { MesProWorkOrderApi, TaskInfo, WorkOrderProgress } from '#/api/mes/pro/workorder';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -16,19 +16,24 @@
  getWorkOrderProgress,
  checkAndAutoFinishWorkOrder,
} from '#/api/mes/pro/workorder';
import { previewAutoGenerate, autoGenerateTasks } from '#/api/mes/pro/task';
import ArchiveDetail from '#/views/mes/pd/archive/modules/detail.vue';
import { useGridColumns, useGridFormSchema, useTaskGridColumns } from './data';
import QuickFeedbackForm from './modules/quick-feedback.vue';
import SchedulePreviewModal from './modules/schedule-preview.vue';
import AutoSchedule from '../task/modules/auto-schedule.vue';
defineOptions({ name: 'WorkorderCenter' });
const archiveDetailRef = ref<InstanceType<typeof ArchiveDetail>>();
const archiveVisible = ref(false);
const archiveProductCode = ref('');
function handleArchive(row: MesProWorkOrderApi.WorkOrder) {
  archiveProductCode.value = row.productCode ?? '';
  archiveVisible.value = true;
}
const router = useRouter();
const progressData = ref<MesProWorkOrderApi.WorkOrderProgress | null>(null);
const progressData = ref<WorkOrderProgress | null>(null);
const activeProcessKeys = ref<string[]>([]);
const expandedRowId = ref<number | null>(null);
@@ -38,7 +43,7 @@
});
const [SchedulePreviewModalWrap, schedulePreviewModalApi] = useVbenModal({
  connectedComponent: SchedulePreviewModal,
  connectedComponent: AutoSchedule,
  destroyOnClose: true,
});
@@ -107,27 +112,9 @@
    .open();
}
/** ä¸€é”®æŽ’产预览 */
async function handleSchedulePreview(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const preview = await previewAutoGenerate({ workOrderId: row.id! });
    schedulePreviewModalApi
      .setData({ workOrder: row, preview })
      .open();
  } catch {
    message.error('获取排产预览失败');
  }
}
/** æ‰§è¡Œä¸€é”®æŽ’产 */
async function handleScheduleExecute(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const result = await autoGenerateTasks({ workOrderId: row.id! });
    message.success(`排产成功,生成 ${result.taskIds?.length || 0} ä¸ªä»»åŠ¡`);
    handleRefresh();
  } catch {
    message.error('排产失败');
  }
/** ä¸Žç”Ÿäº§æŽ’产共用校验、预览和执行流程。 */
function handleSchedulePreview(row: MesProWorkOrderApi.WorkOrder) {
  schedulePreviewModalApi.setData({ workOrderIds: [row.id!] }).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -193,26 +180,16 @@
        <TableAction
          :actions="[
            {
              label: '排产预览',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: row.quantityScheduled < row.quantity,
              onClick: handleSchedulePreview.bind(null, row),
            },
            {
              label: '一键排产',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: row.quantityScheduled < row.quantity,
              popConfirm: {
                title: '确认一键排产?将按照工艺路线自动生成任务。',
                confirm: handleScheduleExecute.bind(null, row),
              },
              ifShow: (row.quantityScheduled ?? 0) < (row.quantity ?? 0),
              onClick: handleSchedulePreview.bind(null, row),
            },
            {
              label: '归档',
              type: 'link',
              onClick: () => archiveDetailRef.value?.open(row.productCode),
              onClick: () => handleArchive(row),
            },
          ]"
        />
@@ -316,6 +293,6 @@
        </Collapse.Panel>
      </Collapse>
    </Card>
    <ArchiveDetail ref="archiveDetailRef" />
    <ArchiveDetail :visible="archiveVisible" :product-code="archiveProductCode" @update:visible="archiveVisible = $event" />
  </Page>
</template>
src/views/mes/pro/workorder/data.ts
@@ -455,11 +455,6 @@
      width: 100,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      width: 120,
@@ -480,15 +475,6 @@
      },
    },
    {
      field: 'productionStatus',
      title: '生产状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ORDER_PRODUCTION_STATUS },
      },
    },
    {
      field: 'processProgressList',
      title: '工序生产进度',
      width: 350,
@@ -504,7 +490,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -622,11 +608,6 @@
      field: 'quantity',
      title: '订单数量',
      width: 100,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
@@ -779,6 +760,17 @@
      width: 160,
    },
    {
      field: 'warehouseName',
      title: '报工仓库',
      width: 130,
      formatter: ({ cellValue, row }) => {
        if (!cellValue) return '-';
        return row.areaName
          ? `${cellValue}/${row.locationName ?? ''}/${row.areaName}`
          : cellValue;
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 120,
src/views/mes/pro/workorder/index.vue
@@ -68,7 +68,7 @@
  /** å®Œæˆè®¢å•前校验 */
  function handleCheckFinish(row: MesProWorkOrderApi.WorkOrder) {
    if (row.productionStatus !== 2) {
    if (row.status !== MesProWorkOrderStatusEnum.FINISHED) {
      Modal.confirm({
        title: '操作确认',
        content: '该订单未完成,是否确认要继续完成?',
src/views/mes/pro/workorder/modules/form.vue
@@ -10,6 +10,7 @@
  import {
    MesProWorkOrderStatusEnum,
  } from "@vben/constants";
  import { isAiEnable } from "@vben/hooks";
  import { Button, message, Tabs } from "ant-design-vue";
@@ -56,6 +57,7 @@
  );
  const canUseAi = computed(
    () =>
      isAiEnable() &&
      formData.value?.id &&
      (formData.value?.status === MesProWorkOrderStatusEnum.PREPARE ||
        formData.value?.status === MesProWorkOrderStatusEnum.CONFIRMED)
src/views/mes/pro/workorder/modules/process-form.vue
@@ -10,11 +10,15 @@
import { useVbenForm } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
import { getRouteProcessListByProduct } from '#/api/mes/pro/route/process';
import {
  createWorkOrderProcess,
  getWorkOrderProcess,
  updateWorkOrderProcess,
} from '#/api/mes/pro/workorder/process';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
import { $t } from '#/locales';
import { MdItemSelect } from '#/views/mes/md/item/components';
@@ -23,6 +27,7 @@
const workOrderId = ref<number>();
const processList = ref<MesProWorkOrderProcessApi.WorkOrderProcess[]>([]);
const isCreateMode = ref(false);
const allowedOutputItemIds = ref<number[]>([]);
const getTitle = computed(() => {
  return formData.value?.id
@@ -39,9 +44,37 @@
  }));
}
/**
 * è½½å…¥æœ¬å·¥å•产品可选的产出物料范围
 *
 * å–工艺路线按产出归属过滤后的产出(通用半成品 + æœ¬äº§å“ä¸“属成品),
 * å¹¶å¹¶å…¥è¯¥å·¥åºå·²é…ç½®çš„产出,避免历史数据在重新选择时被误清空。
 */
async function loadAllowedOutputItemIds(
  productId?: number,
  currentOutputItemIds: number[] = [],
): Promise<number[]> {
  if (!productId) {
    return [];
  }
  const ids = new Set<number>([productId, ...currentOutputItemIds]);
  const routeProcessList = await getRouteProcessListByProduct(productId);
  (routeProcessList || []).forEach((process) => {
    (process.outputItemIds || []).forEach((id) => {
      if (id) {
        ids.add(id);
      }
    });
  });
  return [...ids];
}
/** è¡¨å• schema */
async function loadSchema(): Promise<VbenFormSchema[]> {
  const processOptions = await loadProcessOptions();
  const [processOptions, warehouseList] = await Promise.all([
    loadProcessOptions(),
    getWarehouseSimpleList(),
  ]);
  return [
    {
      fieldName: 'id',
@@ -50,6 +83,21 @@
    },
    {
      fieldName: 'workOrderId',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'warehouseName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'locationName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'areaName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
@@ -185,6 +233,80 @@
      componentProps: {
        multiple: true,
        placeholder: '请选择产出物料',
        productIds: allowedOutputItemIds.value,
      },
    },
    {
      fieldName: 'warehouseId',
      label: '报工仓库',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: (warehouseList || []).map((item) => ({
          label: item.name ?? '',
          value: item.id!,
        })),
        placeholder: '默认取工艺路线配置,可修改',
        onChange: async (value: number) => {
          const warehouse = (warehouseList || []).find(
            (item) => item.id === value,
          );
          await formApi.setFieldValue('warehouseName', warehouse?.name);
          await formApi.setFieldValue('locationId', undefined);
          await formApi.setFieldValue('locationName', undefined);
          await formApi.setFieldValue('areaId', undefined);
          await formApi.setFieldValue('areaName', undefined);
        },
      },
    },
    {
      fieldName: 'locationId',
      label: '报工库区',
      component: 'Select',
      dependencies: {
        triggerFields: ['warehouseId'],
        disabled: (values) => !values.warehouseId,
        async componentProps(values) {
          const options = values.warehouseId
            ? await getWarehouseLocationSimpleList(values.warehouseId)
            : [];
          return {
            allowClear: true,
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工库区',
            onChange: async (value: number) => {
              const location = options.find((item) => item.id === value);
              await formApi.setFieldValue('locationName', location?.name);
              await formApi.setFieldValue('areaId', undefined);
              await formApi.setFieldValue('areaName', undefined);
            },
          };
        },
      },
    },
    {
      fieldName: 'areaId',
      label: '报工库位',
      component: 'Select',
      dependencies: {
        triggerFields: ['locationId'],
        disabled: (values) => !values.locationId,
        async componentProps(values) {
          const options = values.locationId
            ? await getWarehouseAreaSimpleList(values.locationId)
            : [];
          return {
            allowClear: true,
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工库位',
            onChange: async (value: number) => {
              const area = options.find((item) => item.id === value);
              await formApi.setFieldValue('areaName', area?.name);
            },
          };
        },
      },
    },
    {
@@ -271,16 +393,9 @@
      formData.value = undefined;
      processList.value = [];
      isCreateMode.value = false;
      allowedOutputItemIds.value = [];
      return;
    }
    modalApi.lock();
    try {
      const schema = await loadSchema();
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    const data = modalApi.getData<{
      id?: number;
      productId?: number;
@@ -296,27 +411,33 @@
    processList.value = data.processList || [];
    isCreateMode.value = !data.workOrderId;
    if (data.row) {
      // ç¼–辑临时列表中的工序
      formData.value = data.row;
      await formApi.setValues(data.row);
    } else if (data.id) {
      // ç¼–辑已保存的工序
      modalApi.lock();
      try {
        formData.value = await getWorkOrderProcess(data.id);
    modalApi.lock();
    try {
      // å…ˆå–工序数据:编辑已保存工序时用于回显,也用于保留其已有产出
      const savedProcess = !data.row && data.id
        ? await getWorkOrderProcess(data.id)
        : undefined;
      formData.value = data.row ?? savedProcess;
      allowedOutputItemIds.value = await loadAllowedOutputItemIds(
        data.productId,
        formData.value?.outputItemIds || [],
      );
      formApi.setState({ schema: await loadSchema() });
      if (formData.value) {
        // ç¼–辑临时列表中的工序 / ç¼–辑已保存的工序
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      } else {
        // æ–°å¢ž
        const maxSort = Math.max(0, ...processList.value.map((item) => item.sort || 0));
        await formApi.setValues({
          colorCode: '#00AEF3',
          sort: maxSort + 1,
        });
      }
    } else {
      // æ–°å¢ž
      formData.value = undefined;
      const maxSort = Math.max(0, ...processList.value.map((item) => item.sort || 0));
      await formApi.setValues({
        colorCode: '#00AEF3',
        sort: maxSort + 1,
      });
    } finally {
      modalApi.unlock();
    }
  },
});
src/views/mes/pro/workorder/modules/process-list.vue
@@ -158,6 +158,12 @@
          backflushFlag: item.backflushFlag,
          outputItemIds: item.outputItemIds,
          outputItemNames: item.outputItemNames,
          warehouseId: item.warehouseId,
          warehouseName: item.warehouseName,
          locationId: item.locationId,
          locationName: item.locationName,
          areaId: item.areaId,
          areaName: item.areaName,
          remark: item.remark,
          bomItems: item.bomItems,
        }));
src/views/mes/pro/workrecord/data.ts
@@ -101,7 +101,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
  ];
}
src/views/mes/process-design/parameter/data.ts
@@ -166,7 +166,7 @@
      field: "createTime",
      title: "创建时间",
      width: 180,
      formatter: "formatDateTime",
      formatter: 'formatDate',
    },
    {
      title: "操作",
src/views/mes/process-design/parameter/index.vue
@@ -120,14 +120,14 @@
              label: $t('ui.actionTitle.create', ['参数']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pd:parameter:create'],
              auth: ['mes:pd-process-param:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pd:parameter:export'],
              auth: ['mes:pd-process-param:export'],
              onClick: handleExport,
            },
          ]"
@@ -143,14 +143,14 @@
              label: '关联工序',
              type: 'link',
              icon: ACTION_ICON.LINK,
              auth: ['mes:pd:parameter:update'],
              auth: ['mes:pd-process-param:update'],
              onClick: handleViewProcessLink.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pd:parameter:update'],
              auth: ['mes:pd-process-param:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
@@ -158,7 +158,7 @@
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pd:parameter:delete'],
              auth: ['mes:pd-process-param:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.paramName]),
                confirm: handleDelete.bind(null, row),
src/views/mes/process-design/process/data.ts
@@ -161,7 +161,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/process-design/route/data.ts
@@ -108,6 +108,16 @@
      },
    },
    {
      fieldName: "productKeyword",
      label: "产品",
      component: "Input",
      componentProps: {
        allowClear: true,
        maxLength: 100,
        placeholder: "输入产品名称或编码",
      },
    },
    {
      fieldName: "status",
      label: "状态",
      component: "Select",
@@ -202,24 +212,30 @@
      rules: "selectRequired",
    },
    {
      fieldName: "yieldRate",
      label: "工序收率",
      component: "InputNumber",
      help: "产出量 Ã· æŠ•入量。填写 0.98 è¡¨ç¤º 98%,未填写按 100% å‚与排产计算。",
      componentProps: {
        class: "!w-full",
        min: 0.0001,
        max: 1,
        step: 0.01,
        precision: 4,
        placeholder: "如 0.98(98%)",
      },
      rules: z.number().min(0.0001).max(1).nullish(),
    },
    {
      fieldName: "linkType",
      label: "与下道工序关系",
      component: "Select",
      help: () =>
        h(
          "div",
          {
            style:
              "white-space: pre-line; max-width: 320px; line-height: 1.6;",
          },
          "0=开始-开始(SS):本工序开始后,下道工序才能开始(可重叠并行开工)\n1=结束-结束(FF):下道工序必须等到本工序结束才能结束(并行但同步完工)\n2=开始-结束(SF):本工序开始后,下道工序才能结束\n3=结束-开始(FS):本工序结束后,下道工序才能开始(默认值)",
        ),
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_LINK_TYPE, "number"),
        placeholder: "请选择",
      },
      rules: z.number().optional(),
      rules: z.number().default(3),
    },
    {
      fieldName: "colorCode",
@@ -410,11 +426,8 @@
  ];
}
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å•ï¼›multiple = true(新增模式)时产品可多选,一次性绑定多个 */
export function useRouteProductFormSchema(
  onItemChange?: (item: any) => void,
  multiple = false,
): VbenFormSchema[] {
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å• */
export function useRouteProductFormSchema(onItemChange?: (item: any) => void): VbenFormSchema[] {
  return [
    {
      fieldName: "id",
@@ -426,29 +439,17 @@
      component: "Input",
      dependencies: { triggerFields: [""], show: () => false },
    },
    // æ–°å¢žï¼ˆå¤šé€‰ï¼‰ï¼šä¸€æ¬¡ç»‘定多个产品;编辑(单选):change å›žå¡«ç¼–码/名称/规格/单位
    multiple
      ? {
          fieldName: "itemIds",
          label: "产品(可多选)",
          component: markRaw(MdItemSelect),
          componentProps: {
            multiple: true,
            placeholder: "请选择要绑定的产品(可多选)",
          },
          formItemClass: "col-span-2",
          rules: "selectRequired",
        }
      : {
          fieldName: "itemId",
          label: "产品",
          component: markRaw(MdItemSelect),
          componentProps: {
            onChange: onItemChange,
          },
          formItemClass: "col-span-2",
          rules: "selectRequired",
        },
    // äº§å“ç‰©æ–™ï¼šä½¿ç”¨ä¸šåŠ¡è‡ªå®šä¹‰é€‰æ‹©å™¨ï¼Œchange å›žå¡«ç¼–码/名称/规格/单位
    {
      fieldName: "itemId",
      label: "产品",
      component: markRaw(MdItemSelect),
      componentProps: {
        onChange: onItemChange,
      },
      formItemClass: "col-span-2",
      rules: "selectRequired",
    },
    {
      fieldName: "quantity",
      label: "生产数量",
src/views/mes/process-design/route/index.vue
@@ -8,19 +8,26 @@
import { getDictLabel } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tooltip } from 'ant-design-vue';
import { Alert, Button, message, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteRoute,
  exportRoute,
  getRoutePage,
  importRoute,
  importRouteTemplate,
  importCompleteRoute,
  importCompleteRouteTemplate,
  updateRouteStatus,
} from '#/api/mes/pro/route';
import { $t } from '#/locales';
import ImportModalComponent from '#/components/import-modal';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
// å·¥è‰ºè®¾è®¡ä¸Žç”Ÿäº§åŸºç¡€æ•°æ®å…±ç”¨åŒä¸€ä¸ªé…ç½®å™¨ï¼Œé¿å…ä¸¤å¥—页面能力不一致。
import Form from '#/views/mes/pro/route/modules/route-configurator.vue';
const { hasAccessByCodes } = useAccess();
const statusEditable = hasAccessByCodes(['mes:pro-route:update']); // æ˜¯å¦å¯åˆ‡æ¢çŠ¶æ€
@@ -30,9 +37,38 @@
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportModalComponent,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
function handleImport() {
  importModalApi
    .setData({
      title: $t('ui.actionTitle.import', ['工艺路线']),
      fileName: '工艺路线导入模板.xls',
      importFn: importRoute,
      templateFn: importRouteTemplate,
    })
    .open();
}
function handleCompleteImport() {
  importModalApi
    .setData({
      title: '批量导入完整工艺路线',
      fileName: '完整工艺路线导入模板.xlsx',
      importFn: importCompleteRoute,
      templateFn: importCompleteRouteTemplate,
      showUpdateSupport: false,
    })
    .open();
}
/** åˆ›å»ºå·¥è‰ºè·¯çº¿ */
@@ -57,7 +93,9 @@
): Promise<boolean | undefined> {
  try {
    await confirm(
      `确认要将"${row.name}"工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
      newStatus === CommonStatusEnum.ENABLE
        ? `确认启用“${row.name}”吗?系统将检查产出入库位置、工序产出和工序投料是否完整。`
        : `确认要将“${row.name}”工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
    );
  } catch {
    return false;
@@ -118,6 +156,14 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Alert
      class="mb-3"
      message="路线配置顺序"
      description="基本信息 â†’ äº§å‡ºäº§å“åŠå…¥åº“仓 â†’ å·¥åºåŠå„工序产出 â†’ å·¥åºæŠ•æ–™ â†’ æ ¡éªŒå¯ç”¨"
      show-icon
      type="info"
    />
    <Grid table-title="工艺路线列表">
      <template #toolbar-tools>
        <TableAction
@@ -136,6 +182,20 @@
              auth: ['mes:pro-route:export'],
              onClick: handleExport,
            },
            {
              label: $t('ui.actionTitle.import', ['工艺路线']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleImport,
            },
            {
              label: '批量导入完整路线',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleCompleteImport,
            },
          ]"
        />
      </template>
src/views/mes/process-design/route/modules/form.vue
@@ -2,28 +2,24 @@
import type { FormType } from '../data';
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { computed, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import { Button, Divider, message, Select } from 'ant-design-vue';
import { message, TabPane, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createRoute, getRoute, updateRoute } from '#/api/mes/pro/route';
import {
  deleteRouteProduct,
  getRouteProductListByRoute,
} from '#/api/mes/pro/route/product';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import ProductForm from './product-form.vue';
import Workbench from './workbench.vue';
import ProcessList from './process-list.vue';
import ProductList from './product-list.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create'); // è¡¨å•模式
const subTab = ref('process'); // å½“前激活的子表 Tab
const formData = ref<MesProRouteApi.Route>();
const isDetail = computed(() => formType.value === 'detail'); // æ˜¯å¦æŸ¥çœ‹æ¨¡å¼
@@ -36,72 +32,6 @@
    : $t('ui.actionTitle.create', ['工艺路线']);
});
// ==================== äº§å“ç»‘定 ====================
const products = ref<MesProRouteProductApi.RouteProduct[]>([]); // å…³è”产品列表
const selectedProductItemId = ref<number>(); // å½“前选中的产品物料编号
const selectedProduct = computed(() =>
  products.value.find((item) => item.itemId === selectedProductItemId.value),
);
const productOptions = computed(() =>
  products.value.map((item) => ({ label: item.itemName, value: item.itemId })),
);
async function loadProducts() {
  const routeId = formData.value?.id;
  if (!routeId) {
    products.value = [];
    return;
  }
  products.value = (await getRouteProductListByRoute(routeId)) || [];
  if (
    !products.value.some((item) => item.itemId === selectedProductItemId.value)
  ) {
    selectedProductItemId.value = products.value[0]?.itemId;
  }
}
const [ProductFormModal, productFormModalApi] = useVbenModal({
  connectedComponent: ProductForm,
  destroyOnClose: true,
});
/** æ‰“开产品弹窗(绑定 / ç¼–辑) */
function openProductForm(row?: MesProRouteProductApi.RouteProduct) {
  if (!formData.value?.id) {
    return;
  }
  productFormModalApi
    .setData(
      row
        ? { id: row.id, routeId: formData.value.id }
        : {
            routeId: formData.value.id,
            boundItemIds: products.value
              .map((item) => item.itemId)
              .filter((id): id is number => id != null),
          },
    )
    .open();
}
/** ç§»é™¤äº§å“ï¼ˆä¼šçº§è”删除该产品的投入物料配置) */
async function handleRemoveProduct() {
  if (!selectedProduct.value?.id) {
    return;
  }
  try {
    await confirm(
      `确认将产品「${selectedProduct.value.itemName}」从该工艺路线移除?其全部投入物料配置将一并删除。`,
    );
  } catch {
    return;
  }
  await deleteRouteProduct(selectedProduct.value.id);
  message.success($t('ui.actionMessage.operationSuccess'));
  await loadProducts();
}
// ==================== åŸºæœ¬ä¿¡æ¯è¡¨å• ====================
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
@@ -129,12 +59,11 @@
    const data = (await formApi.getValues()) as MesProRouteApi.Route;
    try {
      if (formType.value === 'create') {
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œè¿›å…¥ã€Œäº§å“ä¸Žå·¥åºé…ç½®ã€å·¥ä½œå°
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤ç»„æˆå·¥åºã€å…³è”äº§å“
        const id = await createRoute(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
        formType.value = 'update';
        await loadProducts();
      } else {
        await updateRoute(data);
        formData.value = { ...formData.value, ...data };
@@ -153,11 +82,10 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      products.value = [];
      selectedProductItemId.value = undefined;
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    subTab.value = 'process';
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
@@ -171,7 +99,6 @@
      formData.value = await getRoute(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await loadProducts();
    } finally {
      modalApi.unlock();
    }
@@ -180,49 +107,20 @@
</script>
<template>
  <Modal :title="getTitle" class="w-11/12">
  <Modal :title="getTitle" class="w-4/5">
    <Form class="mx-4" />
    <!-- ç¼–辑/详情模式下展示「产品与工序配置」工作台,新增模式下隐藏 -->
    <template v-if="formData?.id">
      <div class="mx-4 mt-4">
        <Divider class="!my-2" orientation="left">
          <span class="text-sm">产品与工序配置</span>
        </Divider>
        <div class="flex flex-wrap items-center gap-3">
          <span class="shrink-0 text-sm font-medium">生产产品</span>
          <Select
            v-model:value="selectedProductItemId"
            class="!w-64"
            allow-clear
            :disabled="isDetail"
            placeholder="请选择或绑定产品"
            :options="productOptions"
          />
          <template v-if="!isDetail">
            <Button type="primary" @click="openProductForm()"> ç»‘定产品 </Button>
            <Button
              :disabled="!selectedProduct"
              @click="openProductForm(selectedProduct)"
            >
              ç¼–辑产品
            </Button>
            <Button danger :disabled="!selectedProduct" @click="handleRemoveProduct">
              ç§»é™¤äº§å“
            </Button>
          </template>
        </div>
        <p v-if="products.length === 0" class="mt-2 text-xs text-gray-400">
          å…ˆç»‘定要生产的物料,再为每道工序配置「投入 + äº§å‡ºã€
        </p>
        <div class="mt-3">
          <Workbench
            :form-type="formType"
            :product="selectedProduct"
            :route-id="formData.id"
          />
        </div>
      </div>
    </template>
    <!-- ç¼–辑/详情模式下展示子表 Tab,新增模式下隐藏 -->
    <Tabs
      v-if="formType !== 'create' && formData?.id"
      v-model:active-key="subTab"
      class="mx-4 mt-4"
    >
      <TabPane key="process" tab="组成工序">
        <ProcessList :form-type="formType" :route-id="formData.id" />
      </TabPane>
      <TabPane key="product" tab="关联产品">
        <ProductList :form-type="formType" :route-id="formData.id" />
      </TabPane>
    </Tabs>
  </Modal>
  <ProductFormModal @success="loadProducts" />
</template>
</template>
src/views/mes/process-design/route/modules/process-form.vue
@@ -31,7 +31,7 @@
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 150,
    labelWidth: 130,
  },
  layout: 'horizontal',
  schema: [],
src/views/mes/process-design/route/modules/product-form.vue
@@ -5,7 +5,7 @@
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Divider, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -16,17 +16,17 @@
import { $t } from '#/locales';
import { useRouteProductFormSchema } from '../data';
import ProductBomList from './product-bom-list.vue';
const emit = defineEmits(['success']);
const formData = ref<MesProRouteProductApi.RouteProduct>();
const boundItemIds = ref<number[]>([]); // å·²ç»‘定本路线的产品物料编号(新增多选时跳过)
const getTitle = computed(() =>
  formData.value?.id
    ? $t('ui.actionTitle.edit', ['工艺路线产品'])
    : $t('ui.actionTitle.create', ['工艺路线产品']),
);
/** MdItemSelect å•选 change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
/** MdItemSelect change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
async function handleItemChange(item?: any) {
  if (!item) {
    return;
@@ -58,62 +58,22 @@
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as MesProRouteProductApi.RouteProduct & {
      itemIds?: number[];
    };
    // æäº¤è¡¨å•
    const data =
      (await formApi.getValues()) as MesProRouteProductApi.RouteProduct;
    try {
      // ç¼–辑模式:更新单个产品
      if (formData.value?.id) {
        await updateRouteProduct(data);
        await modalApi.close();
        emit('success');
        message.success($t('ui.actionMessage.operationSuccess'));
        return;
        // ç”¨æœ€æ–°è¡¨å•值同步 formData,确保产品 BOM å­è¡¨ç»‘定的 itemId ä¸Žè¡¨å•一致
        formData.value = { ...formData.value, ...data };
      } else {
        // æ–°å¢žæˆåŠŸåŽåˆ‡æ¢åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤äº§å“ BOM
        const id = await createRouteProduct(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
      }
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ä¸€æ¬¡æ€§ç»‘定,跳过已在路线中的产品
      const itemIds = Array.isArray(data.itemIds) ? data.itemIds : [];
      if (itemIds.length === 0) {
        message.warning('请至少选择一个产品');
        return;
      }
      let created = 0;
      let skipped = 0;
      const failedIds: number[] = [];
      for (const itemId of itemIds) {
        if (itemId == null) {
          continue;
        }
        if (boundItemIds.value.includes(itemId)) {
          skipped++;
          continue;
        }
        try {
          await createRouteProduct({
            routeId: data.routeId,
            itemId,
            quantity: data.quantity,
            productionTime: data.productionTime,
            timeUnitType: data.timeUnitType,
            remark: data.remark,
          });
          created++;
        } catch {
          failedIds.push(itemId);
        }
      }
      if (skipped > 0) {
        message.warning(`已跳过 ${skipped} ä¸ªå·²ç»‘定的产品`);
      }
      if (failedIds.length > 0) {
        message.error(
          `有 ${failedIds.length} ä¸ªäº§å“ç»‘定失败(可能已被其它工艺路线占用)`,
        );
      }
      if (created > 0) {
        message.success(`成功绑定 ${created} ä¸ªäº§å“`);
        await modalApi.close();
        emit('success');
      }
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
@@ -121,42 +81,24 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      boundItemIds.value = [];
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      routeId: number;
      boundItemIds?: number[];
    }>();
    if (!data) {
      return;
    }
    boundItemIds.value = data.boundItemIds || [];
    if (!data.id) {
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ç»‘定
      formData.value = undefined;
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, true),
      });
      await formApi.setValues({
        routeId: data.routeId,
        itemIds: [],
        quantity: 1,
        productionTime: 1,
        timeUnitType: 'MINUTE',
        remark: undefined,
      });
      await formApi.setValues({ routeId: data.routeId });
      return;
    }
    // ç¼–辑模式:加载单个产品回填
    modalApi.lock();
    try {
      formData.value = await getRouteProduct(data.id);
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, false),
      });
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
@@ -166,7 +108,18 @@
</script>
<template>
  <Modal :title="getTitle" class="w-2/5">
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
    <!-- ç¼–辑模式下展示产品 BOM å­è¡¨ï¼Œæ–°å¢žæ¨¡å¼ä¸‹éšè— -->
    <template v-if="formData?.id && formData?.itemId">
      <Divider class="!my-3" orientation="left">产品 BOM é…ç½®</Divider>
      <div class="mx-4">
        <ProductBomList
          :route-id="formData.routeId!"
          :product-id="formData.itemId"
          :product-name="formData.itemName"
        />
      </div>
    </template>
  </Modal>
</template>
</template>
src/views/mes/process-design/route/modules/workbench.vue
@@ -406,6 +406,9 @@
              </div>
              <div class="mt-1 flex flex-wrap gap-x-5 gap-y-1 text-sm text-gray-500">
                <span>序号:{{ selectedProcess.sort }}</span>
                <span>
                  å·¥åºæ”¶çŽ‡ï¼š{{ selectedProcess.yieldRate == null ? '未设置(按 100%)' : `${Number((selectedProcess.yieldRate * 100).toFixed(2))}%` }}
                </span>
                <span>准备时间:{{ selectedProcess.prepareTime ?? 0 }} åˆ†</span>
                <span>等待时间:{{ selectedProcess.waitTime ?? 0 }} åˆ†</span>
                <span v-if="selectedProcess.linkType != null">
@@ -542,4 +545,4 @@
      </div>
    </div>
  </div>
</template>
</template>
src/views/mes/process-design/template/data.ts
@@ -125,7 +125,7 @@
      field: "createTime",
      title: "创建时间",
      width: 180,
      formatter: "formatDateTime",
      formatter: 'formatDate',
    },
    {
      title: "操作",
src/views/mes/process-design/template/index.vue
@@ -106,14 +106,14 @@
              label: $t('ui.actionTitle.create', ['模板']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pd:template:create'],
              auth: ['mes:pd-process-param-template:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pd:template:export'],
              auth: ['mes:pd-process-param-template:export'],
              onClick: handleExport,
            },
          ]" />
@@ -134,7 +134,7 @@
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pd:template:update'],
              auth: ['mes:pd-process-param-template:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
@@ -142,7 +142,7 @@
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pd:template:delete'],
              auth: ['mes:pd-process-param-template:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.templateName]),
                confirm: handleDelete.bind(null, row),
src/views/mes/qc/batchtrace/data.ts
@@ -56,15 +56,6 @@
        placeholder: '请输入销售订单编号',
      },
    },
    {
      fieldName: 'purchaseOrderCode',
      label: '采购订单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入采购订单编号',
      },
    },
  ];
}
@@ -97,19 +88,9 @@
      width: 80,
    },
    {
      field: 'vendorCode',
      title: '供应商编码',
      minWidth: 140,
    },
    {
      field: 'vendorName',
      title: '供应商名称',
      minWidth: 160,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 140,
    },
    {
      field: 'clientName',
@@ -119,11 +100,6 @@
    {
      field: 'salesOrderCode',
      title: '销售订单编号',
      minWidth: 160,
    },
    {
      field: 'purchaseOrderCode',
      title: '采购订单编号',
      minWidth: 160,
    },
    {
@@ -192,24 +168,12 @@
      span: 3,
    },
    {
      field: 'purchaseOrderCode',
      label: '采购订单编号',
    },
    {
      field: 'vendorCode',
      label: '供应商编码',
    },
    {
      field: 'vendorName',
      label: '供应商名称',
    },
    {
      field: 'salesOrderCode',
      label: '销售订单编号',
    },
    {
      field: 'clientCode',
      label: '客户编码',
    },
    {
      field: 'clientName',
@@ -242,17 +206,7 @@
      field: 'receiptDate',
      title: '入库日期',
      minWidth: 160,
      formatter: 'formatDateTime',
    },
    {
      field: 'purchaseOrderCode',
      title: '采购订单编号',
      minWidth: 160,
    },
    {
      field: 'vendorCode',
      title: '供应商编码',
      minWidth: 140,
      formatter: 'formatDate',
    },
    {
      field: 'vendorName',
@@ -303,7 +257,7 @@
      field: 'salesDate',
      title: '出库日期',
      minWidth: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'salesOrderCode',
src/views/mes/qc/batchtrace/modules/trace-detail.vue
@@ -11,18 +11,15 @@
import { useDetailSchema } from '../data';
import ProcessTraceList from './process-trace-list.vue';
import PurchaseSourceList from './purchase-source-list.vue';
import SalesTargetList from './sales-target-list.vue';
import TraceList from './trace-list.vue';
const detailData = ref<MesWmBatchApi.Batch>(); // å½“前批次详情
const subTabsName = ref<
  | 'backward'
  | 'forward'
  | 'purchase'
  | 'sales'
  | 'process'
>('forward'); // å½“前激活的追溯页签
>('backward'); // å½“前激活的追溯页签
const [Descriptions] = useDescription({
  bordered: true,
@@ -37,7 +34,7 @@
      return;
    }
    detailData.value = modalApi.getData<MesWmBatchApi.Batch>();
    subTabsName.value = 'forward';
    subTabsName.value = 'backward';
  },
});
</script>
@@ -56,14 +53,6 @@
      class="mx-4 mt-4"
      type="card"
    >
      <Tabs.TabPane key="forward">
        <template #tab>
          <Tooltip title="顺着生产流往下游查:该批次被哪些工单消耗后,产出成了哪些成品批次">
            <span>向前追溯</span>
          </Tooltip>
        </template>
        <TraceList :batch-code="detailData.code" direction="forward" />
      </Tabs.TabPane>
      <Tabs.TabPane key="backward">
        <template #tab>
          <Tooltip title="逆着生产流往上游查:生产该批次时,消耗了哪些原料批次">
@@ -71,14 +60,6 @@
          </Tooltip>
        </template>
        <TraceList :batch-code="detailData.code" direction="backward" />
      </Tabs.TabPane>
      <Tabs.TabPane key="purchase">
        <template #tab>
          <Tooltip title="原料侧来源:该批次对应的采购入库单与 IQC æ¥æ–™æ£€éªŒè®°å½•">
            <span>采购来源</span>
          </Tooltip>
        </template>
        <PurchaseSourceList :batch-code="detailData.code" />
      </Tabs.TabPane>
      <Tabs.TabPane key="sales">
        <template #tab>
src/views/mes/qc/components/ai-assistant.vue
@@ -2,6 +2,7 @@
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { Empty, Input, Spin } from 'ant-design-vue';
@@ -43,7 +44,7 @@
</script>
<template>
  <a-button type="default" @click="modalApi.open()">
  <a-button v-if="isAiEnable()" type="default" @click="modalApi.open()">
    è´¨æ£€åŠ©æ‰‹
  </a-button>
src/views/mes/qc/components/ai-suggest.vue
@@ -4,6 +4,7 @@
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, message, Tag } from 'ant-design-vue';
@@ -51,7 +52,12 @@
</script>
<template>
  <Button type="default" :loading="loading" @click="handleSuggest">
  <Button
    v-if="isAiEnable()"
    type="default"
    :loading="loading"
    @click="handleSuggest"
  >
    <template #icon>
      <IconifyIcon icon="ant-design:robot-outlined" />
    </template>
src/views/mes/qc/defect/data.ts
@@ -170,7 +170,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/qc/indicator/data.ts
@@ -323,7 +323,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/qc/indicatorresult/components/data.ts
@@ -11,11 +11,12 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
/** è¡¨å•类型 */
export type FormType = 'create' | 'update';
export type FormType = 'create' | 'update' | 'detail';
/** æ–°å¢ž/修改检测结果的表单 */
export function useQcIndicatorResultFormSchema(
  formApi?: VbenFormApi,
  readonly = false,
): VbenFormSchema[] {
  return [
    {
@@ -34,7 +35,7 @@
        placeholder: '请输入样品编号',
      },
      rules: 'required',
      suffix: () =>
      suffix: readonly ? undefined : () =>
        h(
          Button,
          {
@@ -48,14 +49,6 @@
          },
          { default: () => '生成' },
        ),
    },
    {
      fieldName: 'sn',
      label: '物资 SN',
      component: 'Input',
      componentProps: {
        placeholder: '请输入物资 SN',
      },
    },
    {
      fieldName: 'remark',
@@ -79,18 +72,13 @@
      width: 200,
    },
    {
      field: 'sn',
      title: '物资SN',
      minWidth: 200,
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 200,
    },
    {
      title: '操作',
      width: 150,
      width: 240,
      fixed: 'right',
      slots: { default: 'actions' },
    },
src/views/mes/qc/indicatorresult/components/form.vue
@@ -40,11 +40,14 @@
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const isDetail = computed(() => formType.value === 'detail');
const items = ref<MesQcIndicatorResultApi.IndicatorResultDetail[]>([]);
const ctxData = ref<CtxData>();
const getTitle = computed(() =>
  formType.value === 'update'
  isDetail.value
    ? '检测结果详情'
    : formType.value === 'update'
    ? $t('ui.actionTitle.edit', ['检验结果'])
    : $t('ui.actionTitle.create', ['检验结果']),
);
@@ -73,9 +76,21 @@
    : [];
}
function getDisplayValue(item: MesQcIndicatorResultApi.IndicatorResultDetail) {
  if (item.value === undefined || item.value === null || item.value === '') {
    return '未填写';
  }
  if (item.valueType === MesQcResultValueType.DICT) {
    return getValueOptions(item.valueSpecification).find(
      (option) => option.value === String(item.value),
    )?.label ?? item.value;
  }
  return item.value;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!ctxData.value) {
    if (!ctxData.value || isDetail.value) {
      return;
    }
    const { valid } = await formApi.validate();
@@ -130,11 +145,18 @@
      items.value = [];
      return;
    }
    formApi.setState({ schema: useQcIndicatorResultFormSchema(formApi) });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<CtxData>();
    ctxData.value = data;
    formType.value = data.formType;
    formApi.setState({
      schema: useQcIndicatorResultFormSchema(formApi, isDetail.value),
    });
    formApi.setDisabled(isDetail.value);
    modalApi.setState({
      showConfirmButton: !isDetail.value,
      cancelText: isDetail.value ? '关闭' : $t('common.cancel'),
    });
    modalApi.lock();
    try {
      const detail = await getIndicatorResultDetail(
@@ -148,7 +170,9 @@
        valueNumber:
          (item.valueType === MesQcResultValueType.FLOAT ||
            item.valueType === MesQcResultValueType.INTEGER) &&
          item.value !== null
          item.value != null &&
          String(item.value).trim() !== '' &&
          Number.isFinite(Number(item.value))
            ? Number(item.value)
            : undefined,
      }));
@@ -157,7 +181,6 @@
        id: detail.id,
        code: detail.code,
        remark: detail.remark,
        sn: detail.sn,
      });
    } finally {
      modalApi.unlock();
@@ -181,8 +204,14 @@
          <AForm.Item
            :label="`检测项${index + 1}:${item.indicatorName ?? ''}`"
          >
            <div v-if="isDetail" class="whitespace-pre-wrap break-words py-1">
              {{ getDisplayValue(item) }}
              <div v-if="item.remark" class="mt-1 text-sm text-muted-foreground">
                å¤‡æ³¨ï¼š{{ item.remark }}
              </div>
            </div>
            <InputNumber
              v-if="
              v-else-if="
                item.valueType === MesQcResultValueType.FLOAT ||
                item.valueType === MesQcResultValueType.INTEGER
              "
src/views/mes/qc/indicatorresult/components/list.vue
@@ -55,6 +55,18 @@
    .open();
}
/** æŸ¥çœ‹æ£€æµ‹ç»“果,质检完成后仍可打开只读详情。 */
function handleDetail(row: MesQcIndicatorResultApi.IndicatorResult) {
  formModalApi
    .setData({
      formType: 'detail',
      id: row.id,
      qcId: props.qcId,
      qcType: props.qcType,
    })
    .open();
}
/** åˆ é™¤æ£€æµ‹ç»“æžœ */
async function handleDelete(row: MesQcIndicatorResultApi.IndicatorResult) {
  const hideLoading = message.loading({
@@ -120,6 +132,12 @@
        <TableAction
          :actions="[
            {
              label: '查看详情',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/mes/qc/ipqc/data.ts
@@ -9,7 +9,6 @@
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProTaskStatusEnum,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -18,7 +17,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
import { ProTaskSelect } from '#/views/mes/pro/task/components';
@@ -157,7 +156,6 @@
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        placeholder: '请选择生产订单',
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        // è®¢å•变更时清空生产任务
        onChange: () => formApi?.setFieldValue('taskId', undefined),
      },
@@ -167,7 +165,6 @@
        componentProps: (values) => ({
          disabled: !!values.sourceDocId,
          placeholder: '请选择生产订单',
          status: MesProWorkOrderStatusEnum.CONFIRMED,
          onChange: () => formApi?.setFieldValue('taskId', undefined),
        }),
      },
@@ -440,11 +437,25 @@
      },
    },
    {
      fieldName: 'inspectorUserId',
      label: '检测人员',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择检测人员',
        showSearch: true,
        optionFilterProp: 'label',
        valueField: 'id',
      },
    },
    {
      fieldName: 'inspectDate',
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
  ];
src/views/mes/qc/iqc/data.ts
@@ -12,9 +12,8 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSupplierSimpleList } from '#/api/srm/supplier';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
@@ -163,26 +162,13 @@
      },
    },
    {
      fieldName: 'vendorId',
      fieldName: 'vendorName',
      label: '供应商',
      component: 'ApiSelect',
      component: 'Input',
      componentProps: {
        allowClear: true,
        api: getSupplierSimpleList,
        labelField: 'name',
        placeholder: '请选择供应商',
        valueField: 'id',
      },
      dependencies: {
        triggerFields: ['sourceDocId'],
        componentProps: (values) => ({
          disabled: !!values.sourceDocId,
          allowClear: true,
          api: getSupplierSimpleList,
          labelField: 'name',
          placeholder: '请选择供应商',
          valueField: 'id',
        }),
        maxlength: 255,
        placeholder: '请输入供应商名称(可留空)',
      },
    },
    {
@@ -192,6 +178,21 @@
      componentProps: {
        placeholder: '请输入供应商批次号',
      },
      suffix: () =>
        h(
          Button,
          {
            disabled: formType === 'detail',
            type: 'default',
            onClick: async () => {
              const code = await generateAutoCode(
                MesAutoCodeRuleCode.QC_IQC_VENDOR_BATCH_CODE,
              );
              await formApi?.setFieldValue('vendorBatch', code);
            },
          },
          { default: () => '生成' },
        ),
    },
    {
      fieldName: 'receivedQuantity',
@@ -309,15 +310,12 @@
      },
    },
    {
      fieldName: 'vendorId',
      fieldName: 'vendorName',
      label: '供应商',
      component: 'ApiSelect',
      component: 'Input',
      componentProps: {
        allowClear: true,
        api: getSupplierSimpleList,
        labelField: 'name',
        placeholder: '请选择供应商',
        valueField: 'id',
        placeholder: '请输入供应商名称',
      },
    },
    {
@@ -352,7 +350,7 @@
      label: '来料日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
    {
@@ -360,7 +358,7 @@
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
    {
@@ -393,8 +391,8 @@
      minWidth: 180,
    },
    {
      field: 'vendorNickname',
      title: '供应商简称',
      field: 'vendorName',
      title: '供应商',
      width: 120,
    },
    {
src/views/mes/qc/iqc/index.vue
@@ -63,7 +63,7 @@
      fileName: '来料检验单导入模板.xls',
      hints: [
        '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:检验单名称、产品物料编码、合格品数量、不合格品数量、检测日期、来料日期。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;「供应商名称」需与「供应商管理」中的名称一致,留空则该单无供应商。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;「供应商名称」非必填,可直接填写名称,无需在系统中建立供应商档案。',
        '合格品数量 + ä¸åˆæ ¼å“æ•°é‡ å¿…须等于「本次接收数量」(留空时自动取两者之和);「检测结果」按字典名称填写(校验通过 / æ ¡éªŒä¸é€šè¿‡ï¼‰ã€‚',
        '「质检方案」填方案名称,留空则按物料自动匹配 IQC æ–¹æ¡ˆï¼›ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;日期支持 2026-01-01 10:00:00 æˆ– 2026-01-01。',
        '逐行导入,失败行不影响成功行;导入后的检验单为「待检验」状态,可在列表中继续编辑、录入检测结果。',
src/views/mes/qc/ledger/data.ts
@@ -8,7 +8,7 @@
import { getDictOptions } from '@vben/hooks';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
/** åˆ—表的搜索表单 */
@@ -29,7 +29,7 @@
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
    {
@@ -179,7 +179,7 @@
      field: 'inspectDate',
      title: '检测日期',
      width: 170,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'status',
src/views/mes/qc/ncr/data.ts
@@ -363,7 +363,7 @@
        allowClear: true,
        class: '!w-full',
        placeholder: '请选择创建时间',
        showTime: true,
        showTime: false,
        type: 'date',
      },
    },
@@ -435,7 +435,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/qc/oqc/data.ts
@@ -13,7 +13,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
@@ -129,6 +129,7 @@
      dependencies: {
        triggerFields: ['sourceDocId'],
        componentProps: (values) => ({
          allCustomers: true,
          disabled: !!values.sourceDocId,
          placeholder: '请选择产品物料',
        }),
@@ -166,14 +167,16 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        placeholder: '请选择客户',
        allCustomers: true,
        allowClear: true,
        placeholder: '请选择客户(可不填)',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['sourceDocId'],
        componentProps: (values) => ({
          disabled: !!values.sourceDocId,
          placeholder: '请选择客户',
          allowClear: true,
          placeholder: '请选择客户(可不填)',
        }),
      },
    },
@@ -339,6 +342,7 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        allCustomers: true,
        placeholder: '请选择客户',
      },
    },
@@ -370,11 +374,25 @@
      },
    },
    {
      fieldName: 'inspectorUserId',
      label: '检测人员',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择检测人员',
        showSearch: true,
        optionFilterProp: 'label',
        valueField: 'id',
      },
    },
    {
      fieldName: 'inspectDate',
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
  ];
src/views/mes/qc/oqc/index.vue
@@ -55,8 +55,8 @@
      importApi: importOqc,
      fileName: '出货检验单导入模板.xls',
      hints: [
        '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:检验单名称、产品物料编码、客户名称、本次出货数量、合格品数量、不合格品数量、检测日期、出货日期。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;「客户名称」需与「CRM å®¢æˆ·ç®¡ç†ã€ä¸­çš„名称一致,且必须在当前登录用户的客户数据权限范围内。',
        '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:检验单名称、产品物料编码、本次出货数量、合格品数量、不合格品数量、检测日期、出货日期。客户名称可选。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;填写客户名称时,需与「CRM å®¢æˆ·ç®¡ç†ã€ä¸­çš„客户名称一致。',
        '合格品数量 + ä¸åˆæ ¼å“æ•°é‡ å¿…须等于「检测数量」(留空时自动取两者之和);「检测结果」按字典名称填写(校验通过 / æ ¡éªŒä¸é€šè¿‡ï¼‰ã€‚',
        '「质检方案」填方案名称,留空则按物料自动匹配 OQC æ–¹æ¡ˆï¼›ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;日期支持 2026-01-01 10:00:00 æˆ– 2026-01-01。',
        '逐行导入,失败行不影响成功行;导入后的检验单为「待检验」状态,可在列表中继续编辑、录入检测结果。',
src/views/mes/qc/pendinginspect/index.vue
@@ -52,6 +52,7 @@
    sourceDocType: row.sourceDocType,
    sourceLineId: row.sourceLineId,
    vendorId: row.vendorId,
    vendorName: row.vendorName,
  };
  iqcFormModalApi.setData({ formType: 'create', prefill }).open();
}
src/views/mes/qc/report/instance/data.ts
@@ -153,7 +153,7 @@
      field: 'createTime',
      title: '生成时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/qc/report/template/data.ts
@@ -217,7 +217,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/qc/report/template/designer/index.vue
@@ -7,7 +7,7 @@
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { isAiEnable, useTabs } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
@@ -48,8 +48,8 @@
const { hasAccessByCodes } = useAccess();
/** AI å¯¼å…¥æ˜¯ç‹¬ç«‹æƒé™ç ï¼šèƒ½æ”¹æ¨¡æ¿ä¸ç­‰äºŽèƒ½ç”¨å¤§æ¨¡åž‹è¯†åˆ«æ–‡ä»¶ï¼ˆæ¯æ¬¡è°ƒç”¨éƒ½è¦èŠ±é’±ï¼‰ */
const canAiImport = computed(() =>
  hasAccessByCodes(['qc-report:template:ai-import']),
const canAiImport = computed(
  () => isAiEnable() && hasAccessByCodes(['qc-report:template:ai-import']),
);
const loading = ref(true);
src/views/mes/qc/rqc/data.ts
@@ -17,7 +17,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
/** è¡¨å•类型 */
@@ -246,9 +246,9 @@
      label: '检测日期',
      component: 'DatePicker',
      componentProps: {
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        placeholder: '请选择检测日期',
        showTime: true,
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
@@ -364,7 +364,7 @@
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
  ];
@@ -442,7 +442,7 @@
      field: 'inspectDate',
      title: '检测日期',
      width: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'inspectorNickname',
src/views/mes/qc/template/data.ts
@@ -185,7 +185,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/tm/tool/data.ts
@@ -11,7 +11,7 @@
  MesToolStatusEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { Button } from 'ant-design-vue';
@@ -181,8 +181,8 @@
      label: '下次保养日期',
      component: 'DatePicker',
      componentProps: {
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: true,
        format: 'YYYY-MM-DD',
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      dependencies: {
@@ -302,7 +302,7 @@
      formatter: ({ row }) => {
        if (row.maintenType === MesMaintenTypeEnum.REGULAR) {
          return row.nextMaintenDate
            ? formatDateTime(row.nextMaintenDate)
            ? formatDate(row.nextMaintenDate)
            : '-';
        }
        if (row.maintenType === MesMaintenTypeEnum.USAGE) {
@@ -326,7 +326,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -416,7 +416,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
  ];
}
src/views/mes/tm/tool/type/data.ts
@@ -211,7 +211,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProFeedbackApi } from '#/api/mes/pro/feedback';
@@ -190,7 +191,7 @@
        field: 'feedbackTime',
        title: '报工时间',
        width: 170,
        formatter: 'formatDateTime',
        formatter: 'formatDate',
      },
      {
        title: '操作',
@@ -318,7 +319,7 @@
            </Popconfirm>
            <!-- å®¡æ‰¹ä¸­ï¼šæŸ¥çœ‹å®¡æ‰¹ -->
            <Button
              v-if="row.status === STATUS_APPROVING"
              v-if="!automaticApproval && row.status === STATUS_APPROVING"
              type="link"
              size="small"
              @click="handleViewProcess(row)"
src/views/mes/workbench/components/tabs/ProductIssueFormModal.vue
@@ -9,7 +9,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesWmProductIssueStatusEnum,
} from '@vben/constants';
@@ -106,7 +105,6 @@
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
      },
    },
    {
src/views/mes/workbench/components/tabs/ProductIssueTab.vue
@@ -8,7 +8,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesWmProductIssueStatusEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
@@ -100,7 +99,6 @@
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        disabled: true,
      },
    },
src/views/mes/workbench/components/tabs/ReturnIssueFormModal.vue
@@ -9,7 +9,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesWmReturnIssueStatusEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -99,7 +98,6 @@
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
      },
    },
    {
src/views/mes/workbench/components/tabs/ReturnIssueTab.vue
@@ -8,7 +8,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesWmReturnIssueStatusEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
@@ -112,7 +111,6 @@
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        disabled: true,
      },
    },
src/views/srm/supplierApply/detail/index.vue
@@ -5,7 +5,7 @@
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
@@ -44,7 +44,7 @@
    {
      label: '申请时间',
      field: 'applyTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '供应商名称', field: 'supplierName', render: textRender },
    { label: '供应商类型', field: 'supplierType', render: textRender },
src/views/srm/supplierApply/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitApply } from '#/api/srm/supplierApply';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SrmSupplierApplyApi } from '#/api/srm/supplierApply';
@@ -70,10 +72,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–ååŒåŠžå…¬å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: SrmSupplierApplyApi.ApplyVO) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitApply(row.id!, 'supplier_apply_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -81,6 +90,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
src/views/system/dept/data.ts
@@ -175,7 +175,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/system/dict/data.ts
@@ -137,7 +137,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -342,7 +342,7 @@
      title: '创建时间',
      field: 'createTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/system/loginlog/data.ts
@@ -5,7 +5,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { getRangePickerDefaultProps } from '#/utils';
@@ -88,7 +88,7 @@
      field: 'createTime',
      title: '登录日期',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -141,7 +141,7 @@
    {
      field: 'createTime',
      label: '登录日期',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
  ];
}
src/views/system/operatelog/data.ts
@@ -5,7 +5,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
@@ -106,7 +106,7 @@
      field: 'createTime',
      title: '操作时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'bizId',
@@ -190,7 +190,7 @@
    {
      field: 'createTime',
      label: '操作时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'bizId',
src/views/system/post/data.ts
@@ -168,7 +168,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/system/post/modules/import-form.vue
@@ -4,11 +4,11 @@
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Upload } from 'ant-design-vue';
import { Button, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importPost, importPostTemplate } from '#/api/system/post';
import { $t } from '#/locales';
import { notifyImportResult } from '#/utils';
import { useImportFormSchema } from '../data';
@@ -34,11 +34,21 @@
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      await importPost(data.file, data.updateSupport);
      // å…³é—­å¹¶æç¤º
      const importData = await importPost(data.file, data.updateSupport);
      const createCount = importData.createPostNames?.length || 0;
      const updateCount = importData.updatePostNames?.length || 0;
      // å…³é—­å¹¶æŒ‰å¯¼å…¥ç»“果提示:仅在确有数据写入时通知列表刷新
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
      if (createCount + updateCount > 0) {
        emit('success');
      }
      notifyImportResult({
        subject: '岗位',
        createCount,
        updateCount,
        failures: importData.failurePostNames,
      });
    } finally {
      modalApi.unlock();
    }
src/views/system/role/data.ts
@@ -253,7 +253,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/system/user/data.ts
@@ -342,7 +342,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/system/user/modules/import-form.vue
@@ -4,11 +4,11 @@
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Upload } from 'ant-design-vue';
import { Button, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importUser, importUserTemplate } from '#/api/system/user';
import { $t } from '#/locales';
import { notifyImportResult } from '#/utils';
import { useImportFormSchema } from '../data';
@@ -34,11 +34,21 @@
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      await importUser(data.file, data.updateSupport);
      // å…³é—­å¹¶æç¤º
      const importData = await importUser(data.file, data.updateSupport);
      const createCount = importData.createUsernames?.length || 0;
      const updateCount = importData.updateUsernames?.length || 0;
      // å…³é—­å¹¶æŒ‰å¯¼å…¥ç»“果提示:仅在确有数据写入时通知列表刷新
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
      if (createCount + updateCount > 0) {
        emit('success');
      }
      notifyImportResult({
        subject: '用户',
        createCount,
        updateCount,
        failures: importData.failureUsernames,
      });
    } finally {
      modalApi.unlock();
    }
src/views/wls/barcode/components/printer-label.vue
@@ -1,30 +1,184 @@
<script lang="ts" setup>
import type { MesWmBarcodeApi } from '#/api/mes/wm/barcode';
import { nextTick, ref } from 'vue';
import { Button, message } from 'ant-design-vue';
import { createBarcode, getBarcodeByBusiness } from '#/api/mes/wm/barcode';
import WlsBarcode from './barcode.vue';
defineOptions({ name: 'WlsPrinterLabel' });
withDefaults(
/** æ ‡ç­¾ä¸Šé™„加展示的业务字段(可选,默认不展示) */
interface LabelField {
  label: string;
  value?: number | string;
}
const props = withDefaults(
  defineProps<{
    bizCode?: string;
    bizId?: number;
    bizType?: string;
    /** ä¸šåŠ¡åç§°ï¼Œä½œä¸ºæ ‡ç­¾ä¸»ä¿¡æ¯å±•ç¤ºåœ¨æ¡ç ä¸‹æ–¹ */
    bizName?: string;
    bizType?: number;
    /** æ ‡ç­¾æ ‡é¢˜ */
    title?: string;
    /** æŒ‰é’®æ–‡æ¡ˆ */
    labelText?: string;
    /** é™„加字段,一般不需要传 */
    fields?: LabelField[];
  }>(),
  {
    bizCode: undefined,
    bizId: undefined,
    bizName: undefined,
    bizType: undefined,
    title: '',
    labelText: '标签打印',
    fields: () => [],
  },
);
function handlePrint() {
  message.warning('标签打印功能暂未实现,敬请期待');
const barcodeRef = ref<InstanceType<typeof WlsBarcode>>();
const barcode = ref<MesWmBarcodeApi.Barcode>();
const renderKey = ref(0);
const printing = ref(false);
/** æ‰“印窗口:需在用户点击时同步打开,否则会被浏览器拦截 */
const printWindow = ref<null | Window>(null);
function escapeHtml(value: string) {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}
/** æž„建标签 HTML:标题 + æ¡ç å›¾ + åç§°ï¼ˆå±…中),附加字段可选 */
function buildLabelHtml(base64: string) {
  const nameHtml = props.bizName
    ? `<div class="label-name">${escapeHtml(props.bizName)}</div>`
    : '';
  const infoHtml = props.fields
    .filter((item) => item.value !== undefined && item.value !== '')
    .map(
      (item) =>
        `<p><span class="info-label">${escapeHtml(item.label)}</span>${escapeHtml(String(item.value))}</p>`,
    )
    .join('');
  return `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>${escapeHtml(props.title || '打印标签')}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:Arial,"Microsoft YaHei",sans-serif;display:flex;justify-content:center;padding:12px}
.label{width:320px;padding:8px;text-align:center}
.label-title{font-size:13px;color:#333;margin-bottom:6px}
.label-barcode{width:200px;margin:0 auto}
.label-barcode img{display:block;width:100%}
.label-name{margin-top:8px;font-size:16px;font-weight:700;line-height:1.4;word-break:break-all}
.label-info{margin-top:8px;font-size:12px;line-height:1.6;text-align:left;color:#333}
.label-info p{margin:2px 0}
.info-label{color:#666;margin-right:4px}
.info-label::after{content:":"}
@page{margin:5mm}
@media print{body{padding:0}.label{width:auto;max-width:100%}}
</style>
</head><body><div class="label">
${props.title ? `<div class="label-title">${escapeHtml(props.title)}</div>` : ''}
<div class="label-barcode"><img src="${base64}" alt="条码" /></div>
${nameHtml}
${infoHtml ? `<div class="label-info">${infoHtml}</div>` : ''}
</div></body></html>`;
}
/** æ¡ç å›¾æ¸²æŸ“完成,把标签内容写入已打开的打印窗口并调起打印 */
function handleBarcodeDone() {
  const win = printWindow.value;
  const base64 = barcodeRef.value?.getImageBase64();
  printWindow.value = null;
  if (!win || win.closed) {
    return;
  }
  if (!base64) {
    message.warning('条码渲染失败,无法打印标签');
    win.close();
    return;
  }
  win.document.open();
  win.document.write(buildLabelHtml(base64));
  win.document.close();
  setTimeout(() => win.print(), 500);
}
/** æ‰“印标签:无条码时先生成,再渲染条码图后打印 */
async function handlePrint() {
  if (!props.bizType || !props.bizId) {
    message.warning('缺少业务类型或业务编号,无法打印标签');
    return;
  }
  if (printing.value) {
    return;
  }
  // åœ¨ç”¨æˆ·æ‰‹åŠ¿å†…åŒæ­¥æ‰“å¼€çª—å£ï¼Œé¿å…å¼¹çª—è¢«æ‹¦æˆª
  const win = window.open('', '_blank');
  if (!win) {
    message.error('无法打开打印窗口,请检查浏览器设置');
    return;
  }
  win.document.write(
    '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial;padding:20px">正在生成标签,请稍候…</body></html>',
  );
  printWindow.value = win;
  printing.value = true;
  try {
    let data = await getBarcodeByBusiness(props.bizType, props.bizId);
    if (!data?.content) {
      await createBarcode({
        bizCode: props.bizCode || '',
        bizId: props.bizId,
        bizName: props.bizName || '',
        bizType: props.bizType,
      });
      data = await getBarcodeByBusiness(props.bizType, props.bizId);
    }
    if (!data?.content) {
      message.warning('条码生成失败,无法打印标签');
      win.close();
      printWindow.value = null;
      return;
    }
    barcode.value = data;
    renderKey.value += 1;
    await nextTick();
  } catch {
    message.error('打印标签失败,请重试');
    win.close();
    printWindow.value = null;
  } finally {
    printing.value = false;
  }
}
</script>
<template>
  <Button type="link" @click="handlePrint">
  <Button :loading="printing" @click="handlePrint">
    {{ labelText }}
  </Button>
  <!-- éšè—çš„æ¡ç æ¸²æŸ“容器,渲染完成后取出图片写入打印窗口 -->
  <div
    v-if="barcode?.content"
    class="pointer-events-none fixed top-0 -left-[9999px] h-0 w-0 overflow-hidden"
  >
    <WlsBarcode
      :key="renderKey"
      ref="barcodeRef"
      :content="barcode.content"
      :format="barcode.format"
      :height="150"
      :width="280"
      @done="handleBarcodeDone"
    />
  </div>
</template>
src/views/wls/barcode/data.ts
@@ -10,10 +10,9 @@
  BarcodeBizTypeEnum,
  CommonStatusEnum,
  DICT_TYPE,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { z } from '#/adapter/form';
import { generateBarcodeContent } from '#/api/mes/wm/barcode';
@@ -224,7 +223,6 @@
      label: '订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        onChange: (item: any) => syncBizDetail(formApi, item),
      },
      dependencies: {
@@ -674,7 +672,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -730,7 +728,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (value) => formatDateTime(value) || '-',
      render: (value) => formatDate(value) || '-',
    },
  ];
}
src/views/wls/batch/data.ts
@@ -208,11 +208,6 @@
      width: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 110,
    },
    {
      field: 'clientName',
      title: '客户名称',
      width: 110,
src/views/wls/itemreceipt/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -232,7 +233,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="ITEM_BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/materialstock/data.ts
@@ -173,7 +173,7 @@
      field: 'receiptTime',
      title: '入库日期',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'frozen',
@@ -373,7 +373,7 @@
      field: 'receiptTime',
      title: '入库日期',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'frozen',
src/views/wls/materialstock/index.vue
@@ -241,7 +241,7 @@
                {
                  label: '移库',
                  type: 'link',
                  auth: ['mes:wm-material-stock:move'],
                  auth: ['mes:wm-material-stock:update'],
                  ifShow: !row.frozen,
                  onClick: handleOpenStockMove.bind(null, row),
                },
src/views/wls/miscreceipt/index.vue
@@ -120,14 +120,14 @@
              label: $t('ui.actionTitle.create', ['杂项入库单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:wm-misc-receipt:create'],
              auth: ['mes:wm:misc-receipt:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:wm-misc-receipt:export'],
              auth: ['mes:wm:misc-receipt:export'],
              onClick: handleExport,
            },
          ]"
@@ -145,7 +145,7 @@
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:wm-misc-receipt:update'],
              auth: ['mes:wm:misc-receipt:update'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.PREPARE,
              onClick: handleEdit.bind(null, row),
            },
@@ -154,7 +154,7 @@
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:wm-misc-receipt:delete'],
              auth: ['mes:wm:misc-receipt:delete'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.PREPARE,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.code]),
@@ -164,7 +164,7 @@
            {
              label: '执行入库',
              type: 'link',
              auth: ['mes:wm-misc-receipt:finish'],
              auth: ['mes:wm:misc-receipt:finish'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVED,
              onClick: handleFinish.bind(null, row),
            },
@@ -172,7 +172,7 @@
              label: '取消',
              type: 'link',
              danger: true,
              auth: ['mes:wm-misc-receipt:cancel'],
              auth: ['mes:wm:misc-receipt:cancel'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVED,
              popConfirm: {
                title: '确认取消该杂项入库单吗?',
src/views/wls/outsourceissue/data.ts
@@ -11,7 +11,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesProWorkOrderTypeEnum,
} from '@vben/constants';
@@ -112,7 +111,6 @@
        onChange: async (workOrder?: MesProWorkOrderApi.WorkOrder) => {
          await formApi?.setFieldValue('vendorId', workOrder?.vendorId);
        },
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        type: MesProWorkOrderTypeEnum.OUTSOURCE,
      },
      rules: 'selectRequired',
src/views/wls/outsourcereceipt/data.ts
@@ -10,7 +10,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesProWorkOrderTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -102,7 +101,6 @@
        onChange: async (workOrder?: MesProWorkOrderApi.WorkOrder) => {
          await formApi?.setFieldValue('vendorId', workOrder?.vendorId);
        },
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        type: MesProWorkOrderTypeEnum.OUTSOURCE,
      },
      rules: 'selectRequired',
src/views/wls/outsourcereceipt/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -227,7 +228,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/packages/data.ts
@@ -8,7 +8,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { Button } from 'ant-design-vue';
@@ -300,11 +299,6 @@
      minWidth: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 100,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 120,
@@ -393,11 +387,6 @@
      minWidth: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 100,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 120,
@@ -449,7 +438,6 @@
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        placeholder: '请选择生产订单',
        status: MesProWorkOrderStatusEnum.CONFIRMED,
      },
      rules: 'selectRequired',
    },
@@ -623,11 +611,6 @@
      field: 'invoiceCode',
      title: '发票编号',
      minWidth: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 100,
    },
    {
      field: 'clientName',
src/views/wls/productissue/data.ts
@@ -11,7 +11,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -100,9 +99,9 @@
      component: 'DatePicker',
      componentProps: {
        class: '!w-full',
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        placeholder: '请选择需求时间',
        showTime: true,
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
@@ -113,7 +112,6 @@
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        placeholder: '请选择生产订单',
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        // è®¢å•变更:清空已选生产任务,避免任务与订单不一致
        onChange: async () => {
          await formApi?.setFieldValue('taskId', undefined);
@@ -242,7 +240,7 @@
      field: 'requiredTime',
      title: '需求时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'status',
src/views/wls/productreceipt/data.ts
@@ -1,5 +1,6 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import type { MesWmProductReceiptApi } from '#/api/mes/wm/productreceipt';
import type { MesWmProductReceiptDetailApi } from '#/api/mes/wm/productreceipt/detail';
@@ -107,8 +108,9 @@
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
        checkMethod: (row: any) => row.productionStatus !== 0,
        checkMethod: (row: MesProWorkOrderApi.WorkOrder) =>
          row.status === MesProWorkOrderStatusEnum.CONFIRMED ||
          row.status === MesProWorkOrderStatusEnum.FINISHED,
      },
    },
    {
src/views/wls/productreceipt/modules/line-list.vue
@@ -244,7 +244,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/productsales/data.ts
@@ -191,6 +191,7 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        allCustomers: true,
        disabled: isHeaderReadonly(formType),
        placeholder: '请选择客户',
      },
@@ -298,6 +299,7 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        allCustomers: true,
        placeholder: '请选择客户',
      },
    },
@@ -349,11 +351,6 @@
      field: 'salesOrderCode',
      title: '销售订单编号',
      minWidth: 160,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 120,
    },
    {
      field: 'clientName',
src/views/wls/returnissue/data.ts
@@ -11,7 +11,6 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -109,7 +108,6 @@
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
      },
      rules: 'selectRequired',
    },
@@ -127,9 +125,9 @@
      component: 'DatePicker',
      componentProps: {
        class: '!w-full',
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        placeholder: '请选择退料日期',
        showTime: true,
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
    },
@@ -225,7 +223,7 @@
      field: 'returnDate',
      title: '退料日期',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'status',
src/views/wls/returnissue/modules/line-list.vue
@@ -241,7 +241,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/returnsales/data.ts
@@ -121,6 +121,7 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        allCustomers: true,
        placeholder: '请选择客户',
      },
      rules: 'selectRequired',
@@ -196,6 +197,7 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        allCustomers: true,
        placeholder: '请选择客户',
      },
    },
@@ -230,11 +232,6 @@
      field: 'salesOrderCode',
      title: '销售订单号',
      minWidth: 140,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 120,
    },
    {
      field: 'clientName',
src/views/wls/returnsales/modules/line-list.vue
@@ -9,6 +9,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -242,7 +243,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/returnvendor/data.ts
@@ -134,9 +134,9 @@
      component: 'DatePicker',
      componentProps: {
        class: '!w-full',
        format: 'YYYY-MM-DD HH:mm:ss',
        format: 'YYYY-MM-DD',
        placeholder: '请选择退货日期',
        showTime: true,
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
@@ -254,7 +254,7 @@
      field: 'returnDate',
      title: '退货日期',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'status',
src/views/wls/returnvendor/index.vue
@@ -176,7 +176,7 @@
            {
              label: '完成退货',
              type: 'link',
              auth: ['mes:wm-return-vendor:update-status'],
              auth: ['mes:wm-return-vendor:finish'],
              ifShow: row.status === MesWmReturnVendorStatusEnum.APPROVED,
              onClick: handleFinish.bind(null, row),
            },
src/views/wls/returnvendor/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -228,7 +229,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/sn/data.ts
@@ -123,7 +123,7 @@
      field: 'createTime',
      title: '生成时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
@@ -171,7 +171,7 @@
      field: 'createTime',
      title: '生成时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/wls/transaction/data.ts
@@ -203,7 +203,7 @@
      field: 'transactionTime',
      title: '事务时间',
      width: 160,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
  ];
}
src/views/wls/transfer/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -220,7 +221,7 @@
          v-if="isStock"
          :biz-code="row.batchCode"
          :biz-id="row.batchId"
          biz-type="BATCH"
          :biz-type="BarcodeBizTypeEnum.BATCH"
        />
      </template>
    </Grid>
src/views/wls/warehouse/area/data.ts
@@ -327,7 +327,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/wls/warehouse/data.ts
@@ -210,7 +210,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/wls/warehouse/location/data.ts
@@ -182,7 +182,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/wms/md/item/brand/data.ts
@@ -96,7 +96,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/wms/md/item/category/data.ts
@@ -174,7 +174,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/wms/md/warehouse/data.ts
@@ -127,7 +127,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/wms/order/check/data.ts
@@ -7,7 +7,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
@@ -334,7 +334,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'creatorName',
@@ -344,7 +344,7 @@
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'updaterName',
src/views/wms/order/check/index.vue
@@ -11,7 +11,7 @@
  OrderStatusEnum,
  OrderUpdateStatusList,
} from '@vben/constants';
import { downloadFileFromBlobPart, formatDateTime } from '@vben/utils';
import { downloadFileFromBlobPart, formatDate } from '@vben/utils';
import { message } from 'ant-design-vue';
@@ -326,11 +326,11 @@
      </template>
      <template #operateInfo="{ row }">
        <div>
          åˆ›å»ºï¼š{{ formatDateTime(row.createTime) || '-' }} /
          åˆ›å»ºï¼š{{ formatDate(row.createTime) || '-' }} /
          {{ row.creatorName || row.creator || '-' }}
        </div>
        <div>
          æ›´æ–°ï¼š{{ formatDateTime(row.updateTime) || '-' }} /
          æ›´æ–°ï¼š{{ formatDate(row.updateTime) || '-' }} /
          {{ row.updaterName || row.updater || '-' }}
        </div>
      </template>
src/views/wms/order/check/modules/print.vue
@@ -7,7 +7,7 @@
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import {
  getCheckOrder,
@@ -144,11 +144,11 @@
        </div>
        <div class="col-span-3 grid grid-cols-2 gap-x-6">
          <div>
            åˆ›å»ºï¼š{{ formatDateTime(printData.createTime) || '-' }} /
            åˆ›å»ºï¼š{{ formatDate(printData.createTime) || '-' }} /
            {{ printData.creatorName || printData.creator || '-' }}
          </div>
          <div>
            æ›´æ–°ï¼š{{ formatDateTime(printData.updateTime) || '-' }} /
            æ›´æ–°ï¼š{{ formatDate(printData.updateTime) || '-' }} /
            {{ printData.updaterName || printData.updater || '-' }}
          </div>
        </div>
src/views/wms/order/movement/data.ts
@@ -7,7 +7,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
@@ -240,7 +240,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'creatorName',
@@ -250,7 +250,7 @@
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'updaterName',
src/views/wms/order/movement/index.vue
@@ -11,7 +11,7 @@
  OrderStatusEnum,
  OrderUpdateStatusList,
} from '@vben/constants';
import { downloadFileFromBlobPart, formatDateTime } from '@vben/utils';
import { downloadFileFromBlobPart, formatDate } from '@vben/utils';
import { message } from 'ant-design-vue';
@@ -295,11 +295,11 @@
      </template>
      <template #operateInfo="{ row }">
        <div>
          åˆ›å»ºï¼š{{ formatDateTime(row.createTime) || '-' }} /
          åˆ›å»ºï¼š{{ formatDate(row.createTime) || '-' }} /
          {{ row.creatorName || row.creator || '-' }}
        </div>
        <div>
          æ›´æ–°ï¼š{{ formatDateTime(row.updateTime) || '-' }} /
          æ›´æ–°ï¼š{{ formatDate(row.updateTime) || '-' }} /
          {{ row.updaterName || row.updater || '-' }}
        </div>
      </template>
src/views/wms/order/movement/modules/print.vue
@@ -7,7 +7,7 @@
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import {
  getMovementOrder,
@@ -112,11 +112,11 @@
        <div>总金额:{{ formatPrice(printData.totalPrice) || '-' }}</div>
        <div class="col-span-3 grid grid-cols-2 gap-x-6">
          <div>
            åˆ›å»ºï¼š{{ formatDateTime(printData.createTime) || '-' }} /
            åˆ›å»ºï¼š{{ formatDate(printData.createTime) || '-' }} /
            {{ printData.creatorName || printData.creator || '-' }}
          </div>
          <div>
            æ›´æ–°ï¼š{{ formatDateTime(printData.updateTime) || '-' }} /
            æ›´æ–°ï¼š{{ formatDate(printData.updateTime) || '-' }} /
            {{ printData.updaterName || printData.updater || '-' }}
          </div>
        </div>
src/views/wms/order/receipt/data.ts
@@ -6,7 +6,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
@@ -289,7 +289,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'creatorName',
@@ -299,7 +299,7 @@
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'updaterName',
src/views/wms/order/receipt/index.vue
@@ -11,7 +11,7 @@
  OrderStatusEnum,
  OrderUpdateStatusList,
} from '@vben/constants';
import { downloadFileFromBlobPart, formatDateTime } from '@vben/utils';
import { downloadFileFromBlobPart, formatDate } from '@vben/utils';
import { message } from 'ant-design-vue';
@@ -300,11 +300,11 @@
      </template>
      <template #operateInfo="{ row }">
        <div>
          åˆ›å»ºï¼š{{ formatDateTime(row.createTime) || '-' }} /
          åˆ›å»ºï¼š{{ formatDate(row.createTime) || '-' }} /
          {{ row.creatorName || row.creator || '-' }}
        </div>
        <div>
          æ›´æ–°ï¼š{{ formatDateTime(row.updateTime) || '-' }} /
          æ›´æ–°ï¼š{{ formatDate(row.updateTime) || '-' }} /
          {{ row.updaterName || row.updater || '-' }}
        </div>
      </template>
src/views/wms/order/receipt/modules/print.vue
@@ -7,7 +7,7 @@
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import {
  getReceiptOrder,
@@ -118,11 +118,11 @@
        <div>总金额:{{ formatPrice(printData.totalPrice) || '-' }}</div>
        <div class="col-span-3 grid grid-cols-2 gap-x-6">
          <div>
            åˆ›å»ºï¼š{{ formatDateTime(printData.createTime) || '-' }} /
            åˆ›å»ºï¼š{{ formatDate(printData.createTime) || '-' }} /
            {{ printData.creatorName || printData.creator || '-' }}
          </div>
          <div>
            æ›´æ–°ï¼š{{ formatDateTime(printData.updateTime) || '-' }} /
            æ›´æ–°ï¼š{{ formatDate(printData.updateTime) || '-' }} /
            {{ printData.updaterName || printData.updater || '-' }}
          </div>
        </div>
src/views/wms/order/shipment/data.ts
@@ -7,7 +7,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
@@ -290,7 +290,7 @@
    {
      field: 'createTime',
      label: '创建时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'creatorName',
@@ -300,7 +300,7 @@
    {
      field: 'updateTime',
      label: '更新时间',
      render: (val) => formatDateTime(val) || '-',
      render: (val) => formatDate(val) || '-',
    },
    {
      field: 'updaterName',
src/views/wms/order/shipment/index.vue
@@ -11,7 +11,7 @@
  OrderStatusEnum,
  OrderUpdateStatusList,
} from '@vben/constants';
import { downloadFileFromBlobPart, formatDateTime } from '@vben/utils';
import { downloadFileFromBlobPart, formatDate } from '@vben/utils';
import { message } from 'ant-design-vue';
@@ -298,11 +298,11 @@
      </template>
      <template #operateInfo="{ row }">
        <div>
          åˆ›å»ºï¼š{{ formatDateTime(row.createTime) || '-' }} /
          åˆ›å»ºï¼š{{ formatDate(row.createTime) || '-' }} /
          {{ row.creatorName || row.creator || '-' }}
        </div>
        <div>
          æ›´æ–°ï¼š{{ formatDateTime(row.updateTime) || '-' }} /
          æ›´æ–°ï¼š{{ formatDate(row.updateTime) || '-' }} /
          {{ row.updaterName || row.updater || '-' }}
        </div>
      </template>
src/views/wms/order/shipment/modules/print.vue
@@ -7,7 +7,7 @@
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import {
  getShipmentOrder,
@@ -118,11 +118,11 @@
        <div>总金额:{{ formatPrice(printData.totalPrice) || '-' }}</div>
        <div class="col-span-3 grid grid-cols-2 gap-x-6">
          <div>
            åˆ›å»ºï¼š{{ formatDateTime(printData.createTime) || '-' }} /
            åˆ›å»ºï¼š{{ formatDate(printData.createTime) || '-' }} /
            {{ printData.creatorName || printData.creator || '-' }}
          </div>
          <div>
            æ›´æ–°ï¼š{{ formatDateTime(printData.updateTime) || '-' }} /
            æ›´æ–°ï¼š{{ formatDate(printData.updateTime) || '-' }} /
            {{ printData.updaterName || printData.updater || '-' }}
          </div>
        </div>
trt.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=TRT"
set "REMOTE_USER=root"
set "REMOTE_HOST=36.213.150.237"
set "REMOTE_PORT=65022"
set "PASSWORD=Sh@1008600"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/data/vue/mom/trt/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
在上述文件截断后对比
vite.config.ts